Compare commits

...
30 Commits
Author SHA1 Message Date
silverwind ddee2e054a fix: align Docker actions, job env and host mode with GitHub (#1268)
- Docker actions evaluate `runs.args` and `runs.env` with their own `inputs`, so a same-named `workflow_dispatch` input no longer replaces the step's `with:` value
- Dockerfile action images are tagged per step and job and removed afterwards, so changed Dockerfiles rebuild and concurrent jobs never share an image
- Workflow and job `env` are evaluated before job and service container credentials and service `env` use them
- Docker actions in host jobs get the host workspace and file-command directory mounted, and no longer inherit the runner's own `PATH`
- Cache server address detection accepts a single interface address

Fixes: https://gitea.com/gitea/runner/issues/390
Fixes: https://gitea.com/gitea/runner/issues/406
Fixes: https://gitea.com/gitea/runner/issues/578
Related: https://gitea.com/gitea/runner/issues/64
Reviewed-on: https://gitea.com/gitea/runner/pulls/1268
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-01 19:45:13 +00:00
silverwindandbircni 629e062f86 fix: drop stalled HTTP/2 connections in action clones (#1266)
go-git sends all concurrent action clones from a host over one HTTP/2 connection, and Go sends no health-check pings by default. When that connection stalls, every clone on it hangs until the job timeout and cancellation can't free them, as in https://gitea.com/gitea/runner/actions/runs/1003885/jobs/1361593.

Ping idle connections so a stalled one is dropped after about 45s.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1266
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-01 16:48:19 +00:00
bircniandsilverwind 73e9552f06 fix: fail fast in register and honor GITEA_MAX_REG_ATTEMPTS (#1197)
`gitea-runner register` pinged the instance in an unbounded loop, so a permanent failure like an untrusted certificate or a wrong URL never returned. It now pings once and returns the error, like other one-shot commands.

The image entrypoint keeps retrying registration indefinitely by default, like the daemon, so a container without a restart policy still recovers once Gitea is reachable. `GITEA_MAX_REG_ATTEMPTS` had no effect because its counter was incremented outside the loop. When set, it now caps the attempts, after which the entrypoint exits non-zero.

Fixes: https://gitea.com/gitea/runner/issues/1195
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1197
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-01 16:01:14 +00:00
silverwindandbircni 05162998eb fix: dind image readiness, exit codes and shutdown order (#1265)
Fixes https://gitea.com/gitea/runner/issues/1263

- Wait for dockerd without a check limit. s6 readiness gave up after 7 checks, so the runner never started on slow hosts, also seen in https://gitea.com/gitea/runner/issues/712, https://gitea.com/gitea/runner/issues/652 and https://gitea.com/gitea/runner/issues/758.
- Let the runner run the docker service's readiness check itself instead of reading s6 status, which can be missing at startup or stale after an unclean stop.
- Exit the container with the first failing service's exit code instead of always 0, so `restart: on-failure` works. A requested stop still exits 0.
- Stop the runner before dockerd, so running jobs can finish within `shutdown_timeout`. The stop grace period has to cover that drain.
- Check the supervision exit codes in `make test-dind`.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1265
Reviewed-by: bircni <[email protected]>
2026-10-01 13:22:25 +00:00
568f89d186 enhance: export the Prometheus metrics over OpenTelemetry (#1248)
Push the runner's Prometheus metrics to the OTLP endpoint next to the job traces, so both land in the same backend.

The `/metrics` registry is converted as the [Prometheus compatibility spec](https://opentelemetry.io/docs/specs/otel/compatibility/prometheus_and_openmetrics/) describes and sent through the existing gRPC-free client, so names stay unchanged and no second metrics stack is linked. Metrics are on by default and pushed every interval and once on shutdown, so ephemeral runners report too.

New standard variables, each `OTEL_EXPORTER_OTLP_*` one with `TRACES` and `METRICS` variants:

- `OTEL_METRICS_EXPORTER`, `none` turns metrics off
- `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_METRIC_EXPORT_TIMEOUT`
- `OTEL_EXPORTER_OTLP_TIMEOUT`, `OTEL_EXPORTER_OTLP_COMPRESSION`
- `OTEL_EXPORTER_OTLP_CERTIFICATE`, `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE`, `OTEL_EXPORTER_OTLP_CLIENT_KEY`

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1248
Reviewed-by: silverwind <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: wmTJc9IK0Q <[email protected]>
2026-10-01 12:55:16 +00:00
bircniandsilverwind d93b7d278a fix: clear consumed ephemeral registration state (#1256)
Ephemeral registrations are single-use, but the daemon kept `.runner` after the task, so a restarted container failed `Declare` with `unregistered runner` forever.

An ephemeral runner now removes its registration file as soon as it fetches a task, so a container killed mid-job registers afresh on restart. It also removes the file when Gitea answers `unregistered runner` at startup or while polling. A proxy's 401 does not count. Persistent runners keep their file, because Gitea sends the same reply for a token mismatch.

Closes https://gitea.com/gitea/runner/issues/818

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1256
Reviewed-by: silverwind <[email protected]>
2026-10-01 09:23:45 +00:00
0cedd98051 feat: add Kubernetes backend (#1260)
Adds a Kubernetes backend. A `name:kubernetes://image` label runs the job as one pod through the Kubernetes API, with services as containers of the same pod. Step env is exported by a script sent over exec stdin, keeping it out of the pod spec and process arguments, and service env goes into a per-job Secret. Docker actions and container options are rejected. See `docs/kubernetes.md`.

Operators shape job pods with `kubernetes.pod_template`, with per-label `pod_templates` merged in `runs-on` order and a `$services` entry for service containers. The idle cleanup removes pods and Secrets a runner left behind.

The client calls the Kubernetes API directly and runs step commands over `coder/websocket` (same dependency used in gitea) instead of pulling in the heavyweight `kubernetes/client-go`. Includes an e2e test against k3s.

Replaces: https://gitea.com/gitea/runner/pulls/1000
Fixes: https://gitea.com/gitea/runner/issues/31

Assisted-by: Claude Code:claude-opus-5-5
Co-authored-by: Zettat123 <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1260
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: Zettat123 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-01 06:59:15 +00:00
07ca4bfa7f enhance: log task, job and run identifiers when a task starts (#1257)
Replace the `task N repo is ...` line with a structured `task started` entry carrying `task_id`, `job_id`, `run_id`, `run_number`, `repository` and the workflow's `job` key, so external supervisors can match runner activity to the job in Gitea. `job_id` is read from the runtime token's claims, tokens and secrets are never logged.

Closes https://gitea.com/gitea/runner/issues/752

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1257
Reviewed-by: silverwind <[email protected]>
2026-09-30 19:42:52 +00:00
78610b01f6 docs: recommend DinD for container jobs (#1258)
Make Docker-in-Docker the recommended and first-shown setup. Mounting the host's Docker socket gives every job root-equivalent access to the host, while DinD gives jobs their own daemon. The default `latest` tag is unchanged.

Also shorten the image flavour docs to what users need to pick an image, and correct that `dind-rootless` needs `--privileged` too.

Closes https://gitea.com/gitea/runner/issues/1227

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1258
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-09-30 18:27:11 +00:00
bircniandsilverwind 4a8ee4ce57 fix: resolve container sleep through PATH (#1255)
Resolve the job container's sleep command through PATH so images with nonstandard binary locations, including Nix, can run jobs.

Closes https://gitea.com/gitea/runner/issues/808

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1255
Reviewed-by: silverwind <[email protected]>
2026-09-29 18:44:57 +00:00
bircniandsilverwind bf03f36d5a fix: honor configured Docker registration file (#1259)
The Docker entrypoint always checked `.runner` in `/data` and ignored `runner.file` from `CONFIG_FILE`. When `runner.file` pointed elsewhere, it re-registered on every start, or skipped registration because of a stale `.runner`. It now reads the path from a new `config runner-file` subcommand, which resolves `runner.file` the same way as `register` and `daemon`.

Closes https://gitea.com/gitea/runner/issues/585

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1259
Reviewed-by: silverwind <[email protected]>
2026-09-29 18:38:35 +00:00
Renovate Botandsilverwind 5a38a3d0c4 chore(deps): update dependencies (#1254)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update |
|---|---|---|---|---|---|
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `v4.10.0` → `v4.10.2` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fbmatcuk%2fdoublestar%2fv4/v4.10.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fbmatcuk%2fdoublestar%2fv4/v4.10.0/v4.10.2?slim=true) | require | patch |
| [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) | `v0.5.16` → `v0.5.17` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fulikunitz%2fxz/v0.5.17?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fulikunitz%2fxz/v0.5.16/v0.5.17?slim=true) |  | patch |
| nginx | `62ff208` → `df221db` |  |  |  | digest |

---

### Release Notes

<details>
<summary>bmatcuk/doublestar (github.com/bmatcuk/doublestar/v4)</summary>

### [`v4.10.2`](https://github.com/bmatcuk/doublestar/releases/tag/v4.10.2): Better support for Windows "Junctions"

[Compare Source](https://github.com/bmatcuk/doublestar/compare/v4.10.1...v4.10.2)

Has improved support for reading "junctions" in Windows (ie, Windows' version of symlinks).

Thanks [@&#8203;x0Lazarus](https://github.com/x0Lazarus) for the PR!

#### What's Changed

- Fix Windows junction traversal with Go 1.23 and later by [@&#8203;x0Lazarus](https://github.com/x0Lazarus) in [#&#8203;117](https://github.com/bmatcuk/doublestar/pull/117)

#### New Contributors

- [@&#8203;x0Lazarus](https://github.com/x0Lazarus) made their first contribution in [#&#8203;117](https://github.com/bmatcuk/doublestar/pull/117)

**Full Changelog**: <https://github.com/bmatcuk/doublestar/compare/v4.10.1...v4.10.2>

### [`v4.10.1`](https://github.com/bmatcuk/doublestar/releases/tag/v4.10.1): Fixed issue with alternatives and character classes

[Compare Source](https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.1)

There was an issue where commas or ending curly brackets inside a character class would cause code looking for the next alternative (commas) or the end of alternatives (ending curly brackets) to erroneously match those characters inside the character classes.

Thanks to [@&#8203;sidsri14](https://github.com/sidsri14) for the fix!

#### What's Changed

- fix brace alternation scanning with character classes by [@&#8203;sidsri14](https://github.com/sidsri14) in [#&#8203;115](https://github.com/bmatcuk/doublestar/pull/115)

#### New Contributors

- [@&#8203;sidsri14](https://github.com/sidsri14) made their first contribution in [#&#8203;115](https://github.com/bmatcuk/doublestar/pull/115)

**Full Changelog**: <https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.1>

</details>

<details>
<summary>ulikunitz/xz (github.com/ulikunitz/xz)</summary>

### [`v0.5.17`](https://github.com/ulikunitz/xz/compare/v0.5.16...v0.5.17)

[Compare Source](https://github.com/ulikunitz/xz/compare/v0.5.16...v0.5.17)

</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-->

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1254
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-28 09:49:36 +00:00
silverwindandbircni 6618e67645 fix: apply a job container without image to the runs-on image (#1252)
Fixes https://gitea.com/gitea/runner/issues/1251, a regression from https://gitea.com/gitea/runner/pulls/1233.

A job `container:` without an `image` key configures the runs-on image again, as in 3.x, so its `volumes`, `options`, `credentials` and `env` apply. An image that evaluates to empty still skips the container, as on GitHub.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1252
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-28 09:43:48 +00:00
silverwind 2b599fae7f fix: misc bug fixed and improvements (#1253)
1. Host mode is selected only through `runs-on` again, as in upstream act.
1. A workflow volume that `valid_volumes` drops no longer displaces the runner's own socket, workspace, tool cache or `/var/run/act` mount, so `/var/run/docker.sock:/var/run/docker.sock` works on hosts whose daemon socket lives elsewhere.
1. `docker://` is stripped from job and service images, as on GitHub.
1. Each created service's log is printed at job end, and service log dumps no longer run workflow commands, as on GitHub.
1. A `--privileged` dropped because privileged mode is off now logs a warning.
1. README: the proxy variables change per step through `env:` or for the rest of a job through `$GITHUB_ENV`, `container.env` does not override them.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1253
Reviewed-by: Zettat123 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-28 05:53:41 +00:00
Zettat123 990ebaa2ac fix: restore the caller's event and inputs in reusable workflow jobs (#1250)
Fixes https://github.com/go-gitea/gitea/issues/38705

Gitea side: https://github.com/go-gitea/gitea/pull/39452

Jobs of a called workflow currently see `gitea.event_name == "workflow_call"`, and their `gitea.event.inputs` is replaced by the caller's `with:`. On GitHub they keep the caller's event, and their `inputs` are the run's `workflow_dispatch` inputs overlaid with the caller's `with:`.

For compatibility with older runners, Gitea keeps sending that form and adds a `gitea_workflow_call` key with the original trigger event name and inputs of the run, and the `inputs` Gitea resolved for the job. When the key is present, the runner restores `gitea.event_name` and `gitea.event.inputs` from it, and uses the resolved `inputs` for the job's expressions and the setup log.

Without that key (older Gitea, or jobs outside called workflows), nothing changes.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1250
Reviewed-by: bircni <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-09-27 16:13:14 +00:00
bircniandsilverwind 81add27459 feat(cache): add shared S3-compatible storage (#1244)
Store the cache used by `actions/cache` in an S3-compatible bucket such as AWS S3 or RustFS, with no `cache-server`. Runners with the same settings use the same cache.

A cache saved on one runner can be restored on the others within 30 seconds. The runner talks to the bucket over the S3 REST API with SigV4, without an SDK. Lifecycle rules on the bucket expire old caches.

*Description rewritten by Claude.*

---------

Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1244
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-09-24 19:46:12 +00:00
silverwindandbircni ace8653105 feat!: serve the actions cache from the runner (#1229)
Make cache requests go through the runner and remove outbound traffic from the cache server. The runner forwards them to `cache.external_server` when one is set, and attaches itself to each job network so jobs can reach it.

Benefits:

1. Jobs need no http proxy rules for the cache server, which `@actions/http-client` mishandles for CIDR ranges in `no_proxy` (https://github.com/actions/toolkit/pull/2495).
2. Only runners reach a shared cache server, which needs no route to Gitea.
3. A runner container sharing the host Docker socket now caches, where jobs previously could not reach it.

Breaks:

1. Jobs that reach `external_server` but not their runner, such as a remote `DOCKER_HOST`. The built-in cache has always needed that reachability, so both modes now agree. Set `cache.host` and `cache.port` to an address those jobs reach.
2. Jobs on `container.network: bridge`, where Docker rejects the alias, so the runner cannot attach and their cache fails as it does today. Leave `container.network` empty, or name a user-defined network.

*Written by Claude.*

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1229
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-24 18:56:07 +00:00
silverwindandbircni 95daa50d9b enhance!: match valid_volumes with bmatcuk/doublestar (#1214)
`valid_volumes` patterns now match with doublestar instead of gobwas/glob, which was compiled without separators so `*` crossed slashes. doublestar is already in the module graph, keeps `*` within one path segment and matches host paths with the OS separator, which fixes Windows patterns where a backslash was read as an escape.

Breaking: a host pattern that relied on `*` matching nested paths needs `**`, e.g. `/data/**` instead of `/data/*`. Named volumes and the bare `**` are unaffected.
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1214
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-24 18:51:12 +00:00
1952e5cdb6 feat: OpenTelemetry tracing (#1206)
Adds OpenTelemetry tracing, configured through the standard `OTEL_*` environment variables and off until an OTLP endpoint is set.

![image.png](https://gitea.com/attachments/c4b49cdf-a32d-403c-94e9-30cd06ee6477)

Each job becomes a trace with a span per step, so operators can see where CI time goes in their existing observability backend. Jobs receive only `TRACEPARENT`, so instrumented tools inside them join the trace without seeing the collector's credentials. Metrics stay on the Prometheus endpoint, which a collector can scrape. See [docs/telemetry.md](https://gitea.com/wmTJc9IK0Q/runner/src/branch/otel/docs/telemetry.md) for setup and security notes.

Also keeps `GITHUB_SHA` and `GITHUB_REF` fixed for a whole run when HEAD moves during `exec` runs and the act tests.

[image.png](https://gitea.com/attachments/c4b49cdf-a32d-403c-94e9-30cd06ee6477)

[image.png](https://gitea.com/attachments/c4b49cdf-a32d-403c-94e9-30cd06ee6477)

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1206
Reviewed-by: bircni <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: wmTJc9IK0Q <[email protected]>
2026-09-24 15:57:18 +00:00
7ad12038d1 feat(runner): add built-in actions framework with checkout (#871)
Adds built-in actions, native Go actions shipped with the runner and selected with `uses: builtin:<name>`. Jobs need no action download and no Node in their image.

The first one is `builtin:checkout`, a subset of `actions/checkout` with `repository`, `ref`, `token`, `path` and `fetch-depth`. Other inputs fail the step. The runner clones shallowly on its host and copies the tree into the job, while `gitea-runner exec` copies the local working directory for the workflow's own ref.

`actions/checkout` and other remote actions are unchanged. A clone ref of `HEAD` now shallow-clones the remote's default branch, which checkout uses for other repositories.

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/871
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-09-24 15:47:58 +00:00
silverwind 98816a605c fix: skip jobs whose if fails to evaluate, update actionslib (#1247)
Updates actionslib to https://gitea.com/gitea/actionslib/releases/tag/v1.2.1.

1. A job whose `if:` fails to evaluate, like `fromJSON` of an invalid job output, is skipped with the error in its log instead of failing, like on GitHub Actions.
1. Workflows the runner reads itself, like with `exec`, are rejected when an expression does not parse, like an unclosed `${{` or an unknown context, instead of failing later.
1. Task payloads from Gitea are read without that check, because they hold evaluated text that may contain `${{`.
1. `if: False`, `if: ~` and other boolean, null or number conditions work.

Gitea PR: https://github.com/go-gitea/gitea/pull/39358

Reviewed-on: https://gitea.com/gitea/runner/pulls/1247
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-24 14:57:40 +00:00
silverwindandbircni e0d00e543d feat: update to actionslib v1.2.0, support self: (#1233)
Update to [actionslib v1.2.0](https://gitea.com/gitea/actionslib/releases/tag/v1.2.0) and move logic to it.

1. `self:{owner}/{repo}[/{path}]@{ref}` in step `uses` resolves on the runner's Gitea instance, for https://github.com/go-gitea/gitea/issues/38990.
2. `strategy.job-index` and `strategy.job-total` come from Gitea when it sends them.
3. Container, services and `with` are evaluated once instead of on every use.
4. Fixes a flaky clone-lock test.

Gitea PR: https://github.com/go-gitea/gitea/pull/39358
Docs PR: https://gitea.com/gitea/docs/pulls/553

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1233
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-23 15:40:19 +00:00
silverwindandbircni ae9ce10f71 fix: register docker-init as subreaper in the dind images (#1241)
The dind images run `s6-svscan` as PID 1, so the `docker-init` that the upstream entrypoint puts in front of `dockerd` cannot reap and warns on every start. `TINI_SUBREAPER` makes it a subreaper for the daemon's subtree, which is what tini recommends when it is not PID 1.

The warning was cosmetic. Orphaned `containerd-shim` processes re-parented to `s6-svscan`, which reaps them just as well, so no zombies accumulated. With this change they re-parent to `docker-init` instead, and the warning is gone.

Seen in https://gitea.com/gitea/runner/issues/1240

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1241
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-23 15:27:15 +00:00
silverwind 3962d0d970 fix: composite pre/post conditions read the job status (#1242)
A composite action's child run context starts with empty step results, so a nested action's `post-if: success()` saw the composite's own result and still ran after the job had failed, which let `actions/cache` save caches from failed jobs.

The `job` context now always comes from the job's run context. Only `success()` and `failure()` in a composite's own main steps read the composite's result, matching GitHub Actions.

Assisted-by: Claude Code:claude-fable-5-1
Reviewed-on: https://gitea.com/gitea/runner/pulls/1242
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-23 15:13:49 +00:00
silverwind d83fa77de4 enhance: support --umask in container.options (#1238)
Re-sync `act/container/docker_cli.go` and `act/container/docker_cli_test.go` to docker/cli v29.8.1, the version go.mod pins. The copies lacked the `--umask` flag added in v29.8.0, so `container.options` rejected it as unknown. Daemons below API 1.56 ignore the setting.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1238
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-22 01:20:16 +00:00
Renovate Bot 58ed5921f3 chore(deps): update dependencies (#1236)
This PR contains the following updates:

| Package | Type | Update | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|---|
| [docker/build-push-action](https://github.com/docker/build-push-action) ([changelog](https://github.com/docker/build-push-action/compare/53b7df96c91f9c12dcc8a07bcb9ccacbed38856a..c3c9e263c25d99ce0380d002d59b67737d91b0dc)) | action | digest | `53b7df9` → `c3c9e26` |  |  |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) ([changelog](https://github.com/docker/setup-buildx-action/compare/37fe631027851001ddb9b187196cc803df7f5f0e..f87e5991a6d7451dcb8d9637bfbc97413f497069)) | action | digest | `37fe631` → `f87e599` |  |  |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) ([changelog](https://github.com/docker/setup-qemu-action/compare/1f40c72289eff860ee54a304f1438e3cff362e0a..99012661954931238ded8c8b007157a8430204e1)) | action | digest | `1f40c72` → `9901266` |  |  |
| [github.com/docker/cli](https://github.com/docker/cli) | require | patch | `v29.8.0+incompatible` → `v29.8.1+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.8.1+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.8.0+incompatible/v29.8.1+incompatible?slim=true) |
| nginx |  | digest | `72ba65e` → `62ff208` |  |  |
| [node](https://github.com/nodejs/node) |  | digest | `6dac556` → `64af381` |  |  |
| [node](https://github.com/nodejs/node) |  | digest | `2fe369e` → `0e0ff40` |  |  |

---

### Release Notes

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

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

[Compare Source](https://github.com/docker/cli/compare/v29.8.0...v29.8.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/1236
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-21 12:22:51 +00:00
silverwind 00ff71a879 chore: remove CLAUDE.md (#1235)
Ref: https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md#21277
Reviewed-on: https://gitea.com/gitea/runner/pulls/1235
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-19 10:40:47 +00:00
silverwind 80db5f61ac fix: pin the podman test image to its immutable tag (#1234)
`quay.io/podman/stable` rebuilds its version tags every night and old digests stop resolving within a day, which breaks `make test-dind TARGET=podman`.

Pin `quay.io/containers/podman:v5.8.4-immutable` instead, its tags are never overwritten, see the [image_build README](https://github.com/containers/image_build/blob/main/README.md).

_Written by Claude._

Reviewed-on: https://gitea.com/gitea/runner/pulls/1234
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-19 10:34:01 +00:00
silverwind 5310d8119a fix: docker proxy probe for podman and containerized runners (#1231)
Podman creates a missing bind source instead of rejecting it, so the Docker proxy probe passed even when the daemon could not see the runner's files. Jobs of Podman runners in a container got an empty directory at `/var/run/docker.sock` (https://gitea.com/gitea/runner/issues/1193#issuecomment-1700501).

The probe now binds the directory and stats its marker through the created container. A runner in a container stats the marker through its own container, so it no longer creates probe containers, which cost up to 2s per job. `make test-dind TARGET=podman` runs the probe against Podman in CI.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1231
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-17 20:01:45 +00:00
silverwind b9c6305dee test: stop the cancellation e2e test racing the job it watches (#1230)
`waitForRunningJobLog` gave one minute to run creation, scheduling, the container start and the first log flush, then blamed the missing log line, which is how https://gitea.com/gitea/runner/actions/runs/927661 failed. It now gets the suite's `runTimeout` and reports the job's status with the run logs on timeout, and the job holds ten seconds instead of two, so neither the marker nor the cancellation arrives after it has exited.

Against gitea 1.28+ this costs nothing, since the cancel ends the job as soon as the marker appears. Against 1.27, which has no cancel route and waits the job out, the suite goes from about 20s to about 23s, as the tests run in parallel.

*Written by Claude.*

Reviewed-on: https://gitea.com/gitea/runner/pulls/1230
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-16 18:40:50 +00:00
123 changed files with 6099 additions and 1584 deletions

No files matched your search

+3 -3
View File
@@ -74,10 +74,10 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4 uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
@@ -94,7 +94,7 @@ jobs:
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+3 -3
View File
@@ -77,10 +77,10 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4 uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
@@ -103,7 +103,7 @@ jobs:
suffix=${{ matrix.variant.tag_suffix }},onlatest=true suffix=${{ matrix.variant.tag_suffix }},onlatest=true
- name: Build and push - name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+4 -2
View File
@@ -30,8 +30,8 @@ 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:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 # renovate: datasource=docker TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # 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
for attempt in 1 2 3; do for attempt in 1 2 3; do
@@ -53,6 +53,8 @@ jobs:
# after `make test` so the images it needs are already present on the host daemon. # after `make test` so the images it needs are already present on the host daemon.
- name: test against dind image - name: test against dind image
run: make test-dind run: make test-dind
- name: test against podman
run: make test-dind TARGET=podman
- name: coverage report - name: coverage report
run: | run: |
make coverage-report make coverage-report
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+5
View File
@@ -31,6 +31,11 @@ depending on the prefix:
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter - Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
decodes exactly those two when folding a location into an annotation. decodes exactly those two when folding a location into an annotation.
## Built-in actions
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
`act/runner/step_action_builtin.go`.
## End-to-end compatibility tests ## End-to-end compatibility tests
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image. `make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
+3
View File
@@ -32,6 +32,8 @@ COPY scripts/s6 /etc/s6
VOLUME /data VOLUME /data
ENV TINI_SUBREAPER=1
ENTRYPOINT ["s6-svscan","/etc/s6"] ENTRYPOINT ["s6-svscan","/etc/s6"]
### DIND-ROOTLESS VARIANT ### DIND-ROOTLESS VARIANT
@@ -56,6 +58,7 @@ VOLUME /data
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
ENV TINI_SUBREAPER=1
USER rootless USER rootless
ENTRYPOINT ["s6-svscan","/etc/s6"] ENTRYPOINT ["s6-svscan","/etc/s6"]
+10 -7
View File
@@ -6,7 +6,13 @@ SHASUM ?= shasum -a 256
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" ) HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
XGO_VERSION := go-1.27.x XGO_VERSION := go-1.27.x
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]6 # renovate: datasource=go GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]7 # renovate: datasource=go
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # renovate: datasource=docker
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
E2E_K3S_IMAGE ?= rancher/k3s:v1.36.4-k3s1@sha256:edad48e12bf81c3a09ac1c05c0c0ffaaa22145980b989d6fae84543a76b83657 # renovate: datasource=docker
LINUX_ARCHS ?= linux/amd64,linux/arm64 LINUX_ARCHS ?= linux/amd64,linux/arm64
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64 DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
@@ -158,17 +164,14 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
@echo "Wrote .tmp/coverage.md" @echo "Wrote .tmp/coverage.md"
.PHONY: test-dind .PHONY: test-dind
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 or podman (TARGET=dind|dind-rootless|podman)
@./scripts/test-dind.sh $(TARGET) @PODMAN_TEST_IMAGE=$(PODMAN_TEST_IMAGE) ./scripts/test-dind.sh $(TARGET)
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:6dac556d980b7f0e5498d08f08cee0ca67798b4ad6c23964a9214920e67758d0 # renovate: datasource=docker
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
E2E_CONCURRENCY ?= 8 E2E_CONCURRENCY ?= 8
.PHONY: test-e2e .PHONY: test-e2e
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh @E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_K3S_IMAGE=$(E2E_K3S_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh
.PHONY: install .PHONY: install
install: $(GOFILES) ## install the runner binary via `go install` install: $(GOFILES) ## install the runner binary via `go install`
+51 -47
View File
@@ -79,53 +79,24 @@ If the registry succeed, it will run immediately. Next time, you could run the r
### Run with docker ### Run with docker
```bash The recommended Docker-in-Docker image runs its own Docker daemon for jobs. Jobs never get the host's Docker socket, which is equivalent to root access on the host.
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:nightly
```
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well — otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist.
### Image flavours
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
| --- | --- | --- | --- | --- | --- |
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
#### `latest` — basic
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
```bash
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
```
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
#### `latest-dind` — Docker-in-Docker
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
```bash ```bash
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \ docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
--name my_runner gitea/runner:latest-dind -v "$PWD/data:/data" -v runner-docker:/var/lib/docker --name my_runner gitea/runner:nightly-dind
``` ```
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged. The volumes keep the runner's registration and the job image cache across container recreation.
#### `latest-dind-rootless` — rootless Docker-in-Docker ### Image flavours
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support). | Tag | Docker daemon for jobs |
| --- | --- |
| `latest-dind` (recommended) | Bundled, needs `--privileged`. |
| `latest-dind-rootless` | Bundled, runs as a non-root user (UID 1000) for stronger isolation. Needs `--privileged` and [rootless Docker](https://docs.docker.com/engine/security/rootless/) support on the host. Keeps its image cache in `/home/rootless/.local/share/docker`. |
| `latest` | None. Mount the host's socket with `-v /var/run/docker.sock:/var/run/docker.sock`, or set `DOCKER_HOST`. |
> **The UID is fixed at 1000.** It comes from the `rootless` user baked into the upstream `docker:dind-rootless` base image, and the bundled daemon always listens on `/run/user/1000/docker.sock` inside the container, so running this flavour as a different user (`--user 1001`) does not work. If you need the runner to talk to a *host* rootless daemon that runs under some other UID, use the `basic` flavour instead and bind-mount that daemon's socket (see [examples/vm/rootless-docker.md](examples/vm/rootless-docker.md)); pointing `DOCKER_HOST` at a host socket from inside `dind-rootless` will not work. Changing the UID otherwise means rebuilding the image from a base with a different `rootless` user. Versioned and `nightly` tags carry the same suffixes, for example `nightly-dind`. The images target Docker, not Podman.
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
### Configuration ### Configuration
@@ -201,10 +172,10 @@ A label is written as:
| Part | Meaning | | Part | Meaning |
| --- | --- | | --- | --- |
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. | | `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. | | `schema` | `docker`, `kubernetes` or `host`. Defaults to `host` when omitted. |
| `args` | Only used by the `docker` schema: the image to run the job in. | | `args` | Only used by the `docker` and `kubernetes` schemas: the image to run the job in. |
Two schemas are supported: Three schemas are supported:
- **`docker://<image>`** — the job runs inside a container created from `<image>`: - **`docker://<image>`** — the job runs inside a container created from `<image>`:
@@ -212,6 +183,12 @@ Two schemas are supported:
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
``` ```
- **`kubernetes://<image>`** — the job runs as a Kubernetes pod created from `<image>`, see **[docs/kubernetes.md](docs/kubernetes.md)**:
```text
ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest
```
- **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there: - **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there:
```text ```text
@@ -226,9 +203,9 @@ ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host. a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas. Names may themselves contain a colon, for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`. Only `host`, `docker` and `kubernetes` are treated as schemas.
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images). If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs in `runner.default_image`: as a container when the runner has `docker` labels, as a pod when it has `kubernetes` labels, else as a container where docker is available and on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings. Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
@@ -298,7 +275,7 @@ These hosts are added to `no_proxy` for jobs, so they are always reached directl
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly. Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it. For one step, set it in the step's `env:`, for the rest of a job, write it to `$GITHUB_ENV`. Setting it at workflow or job level or in `container.env` has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not. Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
@@ -306,9 +283,13 @@ Dockerfile actions are built with these variables as build arguments, so their `
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment. A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
#### Built-in actions (`uses: builtin:<name>`)
Use `uses: builtin:checkout` to check out a repository without downloading `actions/checkout` or needing Node in the job image, see **[docs/builtin-actions.md](docs/builtin-actions.md)**.
#### Caching (`actions/cache`) #### Caching (`actions/cache`)
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`. Each runner starts its own cache server, so runners do not share cached entries. A runner in a container on the jobs' Docker daemon attaches itself to each job network, so jobs reach its cache without extra setup.
**Sharing a cache between runners** **Sharing a cache between runners**
@@ -333,10 +314,28 @@ cache:
external_secret: "<secret>" 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. `--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. Runners can also share one `cache.dir` on a file system with working file locks, at the cost of slower cache requests.
**Storing the cache in an S3-compatible bucket**
The cache can also be stored in an S3-compatible bucket such as AWS S3, MinIO or RustFS, with no cache server. Runners with the same settings use the same cache:
```yaml
cache:
s3:
endpoint: "http://minio:9000"
region: "us-east-1"
bucket: "runner-cache"
prefix: "gitea-runner-cache"
path_style: true
access_key_file: "/run/secrets/cache-s3-access-key"
secret_key_file: "/run/secrets/cache-s3-secret-key"
```
A cache saved on one runner can be restored on the others within 30 seconds. The eviction settings below do not apply to the bucket, so add lifecycle rules to it that expire old caches and abort incomplete multipart uploads.
**Eviction** **Eviction**
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. 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.
@@ -358,6 +357,7 @@ While the runner is idle it cleans up after earlier jobs:
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed - only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
- cleanup assumes `container.workdir_parent` is not shared across multiple runners - cleanup assumes `container.workdir_parent` is not shared across multiple runners
- on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid - on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
- on runners with `kubernetes` labels, job pods with that label older than `runner.workdir_cleanup_age` are removed the same way, together with their Secrets
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above - cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
#### Post-task script (`runner.post_task_script`) #### Post-task script (`runner.post_task_script`)
@@ -380,6 +380,10 @@ Both hooks are synchronous and block the job while they run. Either one exiting
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes. See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
#### OpenTelemetry (`OTEL_*`)
Set `OTEL_EXPORTER_OTLP_ENDPOINT` in the runner's environment to send a trace of every job, and the runner's metrics, to an OpenTelemetry collector. See **[docs/telemetry.md](docs/telemetry.md)**.
#### Local job logs (`log.job.dir`) #### Local job logs (`log.job.dir`)
Set `log.job.dir` to a path and the runner writes a copy of every task's log there as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same secrets masked and the job's result on the last line. Off by default, and what Gitea shows does not change. Set `log.job.dir` to a path and the runner writes a copy of every task's log there as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same secrets masked and the job's result on the last line. Off by default, and what Gitea shows does not change.
+182 -25
View File
@@ -10,6 +10,7 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/binary"
"encoding/hex" "encoding/hex"
"encoding/json/v2" "encoding/json/v2"
"errors" "errors"
@@ -17,6 +18,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/http/httputil"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -57,10 +59,9 @@ type JobCredential struct {
Repo string `json:"repo"` Repo string `json:"repo"`
// Results is the instance whose artifact service this server forwards for the job, and // Results is the instance whose artifact service this server forwards for the job, and
// InsecureTLS how the runner reaches it; see results.go. The tags are the wire format a // InsecureTLS how the runner reaches it; see results.go.
// remote runner registers with. Results string `json:"-"`
Results string `json:"results"` InsecureTLS bool `json:"-"`
InsecureTLS bool `json:"insecure_tls"`
// PublicURL is this server as a reverse proxy makes the job reach it, not the listen address. // PublicURL is this server as a reverse proxy makes the job reach it, not the listen address.
PublicURL string `json:"public_url"` PublicURL string `json:"public_url"`
@@ -78,7 +79,8 @@ type credEntry struct {
type Handler struct { type Handler struct {
dir string dir string
storage *Storage storage cacheStorage
s3 *s3Storage
router *httprouter.Router router *httprouter.Router
listener net.Listener listener net.Listener
port int port int
@@ -88,6 +90,9 @@ type Handler struct {
gcing atomic.Bool gcing atomic.Bool
gcAt time.Time gcAt time.Time
stopSync context.CancelFunc
syncing sync.WaitGroup
outboundIP string outboundIP string
// internalSecret guards /_internal/{register,revoke}. When set, a remote // internalSecret guards /_internal/{register,revoke}. When set, a remote
@@ -117,6 +122,8 @@ type Options struct {
Dir string Dir string
OutboundIP string OutboundIP string
Port uint16 Port uint16
S3 *S3Options
Upstream string
// InternalSecret, when non-empty, enables a control-plane API at // InternalSecret, when non-empty, enables a control-plane API at
// /_internal/{register,revoke} that lets a remote runner pre-register the // /_internal/{register,revoke} that lets a remote runner pre-register the
@@ -129,7 +136,7 @@ type Options struct {
Logger logrus.FieldLogger Logger logrus.FieldLogger
} }
// StartHandler opens the on-disk cache store and starts the HTTP server. // StartHandler starts an embedded cache or forwards cache requests to Upstream.
func StartHandler(opts Options) (*Handler, error) { func StartHandler(opts Options) (*Handler, error) {
dir, logger := opts.Dir, opts.Logger dir, logger := opts.Dir, opts.Logger
h := &Handler{ h := &Handler{
@@ -147,6 +154,25 @@ func StartHandler(opts Options) (*Handler, error) {
logger = logger.WithField("module", "artifactcache") logger = logger.WithField("module", "artifactcache")
h.logger = logger h.logger = logger
if opts.OutboundIP != "" {
h.outboundIP = opts.OutboundIP
} else if ip := common.GetOutboundIP(); ip == nil {
return nil, errors.New("unable to determine outbound IP address")
} else {
h.outboundIP = ip.String()
}
if opts.Upstream != "" {
handler, err := h.cacheForwarder(opts.Upstream)
if err != nil {
return nil, err
}
if err := h.serve(opts.Port, handler); err != nil {
return nil, err
}
return h, nil
}
if dir == "" { if dir == "" {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
@@ -165,13 +191,11 @@ func StartHandler(opts Options) (*Handler, error) {
return nil, err return nil, err
} }
h.storage = storage h.storage = storage
if opts.S3 != nil {
if opts.OutboundIP != "" { if h.s3, err = newS3Storage(storage, *opts.S3); err != nil {
h.outboundIP = opts.OutboundIP return nil, err
} else if ip := common.GetOutboundIP(); ip == nil { }
return nil, errors.New("unable to determine outbound IP address") h.storage = h.s3
} else {
h.outboundIP = ip.String()
} }
secret, err := loadOrCreateSecret(dir) secret, err := loadOrCreateSecret(dir)
@@ -201,34 +225,74 @@ func StartHandler(opts Options) (*Handler, error) {
h.gcCache() h.gcCache()
if err := h.serve(opts.Port, router); err != nil {
return nil, err
}
if h.s3 != nil {
ctx, cancel := context.WithCancel(context.Background())
h.stopSync = cancel
h.syncing.Go(func() { h.syncMetadata(ctx) })
}
return h, nil
}
func (h *Handler) serve(port uint16, handler http.Handler) error {
// Listen on all interfaces. Binding to outboundIP only would give no real // Listen on all interfaces. Binding to outboundIP only would give no real
// security benefit (it is the LAN/internet-facing address either way) and // security benefit (it is the LAN/internet-facing address either way) and
// can break Docker Desktop variants where the host's outbound IP is not // can break Docker Desktop variants where the host's outbound IP is not
// routable from inside the container network. Authentication is enforced // routable from inside the container network. Authentication is enforced
// by the bearer middleware and per-repo scoping, not by reachability. // by the bearer middleware and per-repo scoping, not by reachability.
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", opts.Port)) listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil { if err != nil {
return nil, err return err
} }
addr, ok := listener.Addr().(*net.TCPAddr) addr, ok := listener.Addr().(*net.TCPAddr)
if !ok { if !ok {
listener.Close() listener.Close()
return nil, fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr()) return fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr())
} }
h.port = addr.Port h.port = addr.Port
server := &http.Server{ server := &http.Server{
ReadHeaderTimeout: 2 * time.Second, ReadHeaderTimeout: 2 * time.Second,
Handler: router, Handler: handler,
} }
go func() { go func() {
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) { if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
logger.Errorf("http serve: %v", err) h.logger.Errorf("http serve: %v", err)
} }
}() }()
h.listener = listener h.listener = listener
h.server = server h.server = server
return h, nil return nil
}
func (h *Handler) cacheForwarder(upstream string) (http.Handler, error) {
target, err := url.Parse(strings.TrimRight(upstream, "/"))
if err != nil {
return nil, fmt.Errorf("parse cache upstream: %w", err)
}
transport, _ := http.DefaultTransport.(*http.Transport)
transport = transport.Clone()
transport.MaxIdleConnsPerHost = 64 // parallel block uploads would otherwise redial past the default 2
proxy := &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target)
r.Out.URL.RawQuery = r.In.URL.RawQuery // Rewrite drops unparsable query params, and signed URLs must pass verbatim.
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
h.logger.Warnf("cache forward %s %s: %v", r.Method, r.URL.Path, err)
h.twirpError(w, r, twirpUnavailable, errors.New("cache upstream is unavailable"))
},
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, apiPath+"/") || strings.HasPrefix(r.URL.Path, cacheServiceV2Path+"/") {
proxy.ServeHTTP(w, r)
} else {
h.forwardOrNotFound(w, r)
}
}), nil
} }
func (h *Handler) ExternalURL() string { func (h *Handler) ExternalURL() string {
@@ -333,6 +397,10 @@ func (h *Handler) Close() error {
if h == nil { if h == nil {
return nil return nil
} }
if h.stopSync != nil {
h.stopSync()
h.syncing.Wait()
}
var retErr error var retErr error
if h.server != nil { if h.server != nil {
err := h.server.Close() err := h.server.Close()
@@ -366,6 +434,72 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
}) })
} }
// syncMetadata mirrors the bucket into the store until ctx ends, so no request waits on S3 for it.
func (h *Handler) syncMetadata(ctx context.Context) {
ticker := time.NewTicker(metadataSyncInterval)
defer ticker.Stop()
for {
if err := h.importMetadata(ctx); err != nil && ctx.Err() == nil {
h.logger.Warnf("sync S3 cache: %v", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// importMetadata lists after snapshotting the store, so an entry committed meanwhile, published before it is stored, stays.
func (h *Handler) importMetadata(ctx context.Context) error {
db, err := h.openDB()
if err != nil {
return err
}
local := h.caches(db, bolthold.Where("Complete").Eq(true).Index("Complete"))
db.Close()
published, err := h.s3.published(ctx)
if err != nil {
return err
}
var gone []*Cache
for _, cache := range local {
if !published[cache.ID] {
gone = append(gone, cache)
}
delete(published, cache.ID)
}
added := make([]*Cache, 0, len(published))
for id := range published {
cache, err := h.s3.fetch(ctx, id)
if err != nil {
return err
}
added = append(added, cache)
}
db, err = h.openDB()
if err != nil {
return err
}
defer db.Close()
return db.Bolt().Update(func(tx *bbolt.Tx) error {
for _, cache := range added {
if err := db.TxUpsert(tx, cache.ID, cache); err != nil {
return err
}
}
for _, cache := range gone {
_ = h.s3.Storage.Remove(cache.ID) // a blob kept on disk before the runner used S3
if err := db.TxDelete(tx, cache.ID, cache); err != nil && !errors.Is(err, bolthold.ErrNotFound) {
return err
}
}
return nil
})
}
// GET /_apis/artifactcache/cache // GET /_apis/artifactcache/cache
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context()) cred := credFromContext(r.Context())
@@ -408,7 +542,7 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
return nil, err return nil, err
} }
if !ok { if !ok {
_ = db.Delete(cache.ID, cache) h.deleteCache(db, cache)
return nil, nil //nolint:nilnil // absence is not an error here return nil, nil //nolint:nilnil // absence is not an error here
} }
// Handing out a download URL counts as access, or eviction could drop the entry between // Handing out a download URL counts as access, or eviction could drop the entry between
@@ -440,7 +574,7 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
now := time.Now().Unix() now := time.Now().Unix()
cache.CreatedAt = now cache.CreatedAt = now
cache.UsedAt = now cache.UsedAt = now
if err := insertCache(db, cache); err != nil { if err := h.insertCache(db, cache); err != nil {
h.responseJSON(w, r, 500, err) h.responseJSON(w, r, 500, err)
return return
} }
@@ -554,6 +688,12 @@ func (h *Handler) commitCache(cache *Cache) error {
cache.Size = written cache.Size = written
cache.Complete = true cache.Complete = true
cache.UsedAt = time.Now().Unix() // a just-written entry counts as accessed, so it cannot be its own eviction victim cache.UsedAt = time.Now().Unix() // a just-written entry counts as accessed, so it cannot be its own eviction victim
if h.s3 != nil {
if err := h.s3.publish(cache); err != nil {
_ = h.storage.Remove(cache.ID)
return err
}
}
db, err := h.openDB() db, err := h.openDB()
if err != nil { if err != nil {
@@ -563,6 +703,9 @@ func (h *Handler) commitCache(cache *Cache) error {
if err := db.Update(cache.ID, cache); err != nil { if err := db.Update(cache.ID, cache); err != nil {
return err return err
} }
if h.s3 != nil {
return nil // lifecycle rules on the bucket bound its size
}
// A commit is the only thing that grows the store, so the only thing that can push the // A commit is the only thing that grows the store, so the only thing that can push the
// volume under the floor. // volume under the floor.
h.evictRepo(db, cache.Repo) h.evictRepo(db, cache.Repo)
@@ -708,8 +851,7 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
return return
} }
h.RegisterJob(body.Token, body.JobCredential) h.RegisterJob(body.Token, body.JobCredential)
// A server too old to forward answers without this, which is how the caller knows. h.responseJSON(w, r, http.StatusOK)
h.responseJSON(w, r, http.StatusOK, map[string]any{"results_url": h.ResultsURL(body.JobCredential)})
} }
// POST /_internal/revoke // POST /_internal/revoke
@@ -827,7 +969,13 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
return cache, nil return cache, nil
} }
func insertCache(db *bolthold.Store, cache *Cache) error { func (h *Handler) insertCache(db *bolthold.Store, cache *Cache) error {
if h.s3 != nil {
var id [8]byte
_, _ = rand.Read(id[:])
cache.ID = binary.BigEndian.Uint64(id[:]) >> 11 // IDs must be unique across runners, and v1 clients parse an ID as a JS number
return db.Insert(cache.ID, cache)
}
return db.Bolt().Update(func(tx *bbolt.Tx) error { return db.Bolt().Update(func(tx *bbolt.Tx) error {
if err := db.TxInsert(tx, bolthold.NextSequence(), cache); err != nil { if err := db.TxInsert(tx, bolthold.NextSequence(), cache); err != nil {
return fmt.Errorf("insert cache: %w", err) return fmt.Errorf("insert cache: %w", err)
@@ -885,6 +1033,8 @@ const (
// a stale reservation. // a stale reservation.
uploadStallTimeout = 5 * time.Minute uploadStallTimeout = 5 * time.Minute
metadataSyncInterval = 30 * time.Second
defaultMinFreeDisk = 1024 * miB defaultMinFreeDisk = 1024 * miB
) )
@@ -932,6 +1082,9 @@ func (h *Handler) gcCache() {
defer db.Close() defer db.Close()
h.evictIncomplete(db) h.evictIncomplete(db)
if h.s3 != nil {
return // lifecycle rules on the bucket expire its entries
}
h.evictExpired(db) h.evictExpired(db)
h.evictSuperseded(db) h.evictSuperseded(db)
h.evictOversized(db) h.evictOversized(db)
@@ -1129,7 +1282,11 @@ func totalSize(caches []*Cache) int64 {
// deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes // deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes
// first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes. // first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes.
func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool { func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool {
if err := h.storage.Remove(cache.ID); err != nil { storage := h.storage
if h.s3 != nil && !cache.Complete {
storage = h.s3.Storage // an upload not yet committed has nothing in the bucket
}
if err := storage.Remove(cache.ID); err != nil {
h.logger.Warnf("remove cache blob: %v", err) h.logger.Warnf("remove cache blob: %v", err)
return false return false
} }
+71 -14
View File
@@ -12,6 +12,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -56,6 +57,55 @@ func TestHandler_ExternalURL(t *testing.T) {
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL()) assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL())
} }
func TestHandler_Upstream(t *testing.T) {
var upstream *httptest.Server
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPut, r.Method)
assert.Equal(t, "/cache%20server"+blobPath+"/%34%32", r.URL.EscapedPath())
assert.Equal(t, "sig=opaque%2Fvalue&raw=a;b", r.URL.RawQuery)
assert.Equal(t, strings.TrimPrefix(upstream.URL, "http://"), r.Host)
assert.Equal(t, "Bearer unregistered", r.Header.Get("Authorization"))
_, err := io.Copy(w, r.Body)
assert.NoError(t, err)
}))
t.Cleanup(upstream.Close)
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(Options{
Dir: dir,
OutboundIP: "127.0.0.1",
Upstream: upstream.URL + "/cache%20server//",
})
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, handler.Close()) })
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut,
handler.ExternalURL()+blobPath+"/%34%32?sig=opaque%2Fvalue&raw=a;b", strings.NewReader("cache content"))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer unregistered")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
forwarded, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, "cache content", string(forwarded))
resp, err = http.Post(handler.ExternalURL()+internalPath+"/register", "application/json", nil)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
upstream.Close()
resp, err = http.Post(handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", "application/json", strings.NewReader(`{}`))
require.NoError(t, err)
var failure map[string]string
err = json.UnmarshalRead(resp.Body, &failure)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, map[string]string{"code": "unavailable", "msg": "cache upstream is unavailable"}, failure)
assert.NoDirExists(t, dir)
}
func TestHandler(t *testing.T) { func TestHandler(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache") dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(Options{Dir: dir}) handler, err := StartHandler(Options{Dir: dir})
@@ -814,7 +864,7 @@ func TestHandler_gcCache(t *testing.T) {
db, err := handler.openDB() db, err := handler.openDB()
require.NoError(t, err) require.NoError(t, err)
for _, c := range cases { for _, c := range cases {
require.NoError(t, insertCache(db, c.Cache)) require.NoError(t, handler.insertCache(db, c.Cache))
} }
require.NoError(t, db.Close()) require.NoError(t, db.Close())
@@ -986,7 +1036,7 @@ func TestHandler_SweepKeepsEntryWhenBlobSurvives(t *testing.T) {
handler := newTestHandler(t, Policy{Retention: testRetention}, cache) handler := newTestHandler(t, Policy{Retention: testRetention}, cache)
// A non-empty directory where the blob belongs makes os.Remove fail on every platform. // A non-empty directory where the blob belongs makes os.Remove fail on every platform.
blob := handler.storage.filename(cache.ID) blob := handler.storage.(*Storage).filename(cache.ID)
require.NoError(t, os.MkdirAll(blob, 0o755)) require.NoError(t, os.MkdirAll(blob, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600))
@@ -1069,24 +1119,28 @@ func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
// 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()
handler, err := StartHandler(Options{ handler := startTestHandler(t, Options{Policy: policy})
Dir: filepath.Join(t.TempDir(), "artifactcache"),
OutboundIP: "127.0.0.1",
Policy: policy,
})
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, handler.Close()) })
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
db, err := handler.openDB() db, err := handler.openDB()
require.NoError(t, err) require.NoError(t, err)
for _, e := range entries { for _, e := range entries {
require.NoError(t, insertCache(db, e)) require.NoError(t, handler.insertCache(db, e))
} }
require.NoError(t, db.Close()) require.NoError(t, db.Close())
return handler return handler
} }
func startTestHandler(t *testing.T, opts Options) *Handler {
t.Helper()
opts.Dir = filepath.Join(t.TempDir(), "artifactcache")
opts.OutboundIP = "127.0.0.1"
handler, err := StartHandler(opts)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, handler.Close()) })
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
return handler
}
// keptKeys reports which of entries are still in the store. // keptKeys reports which of entries are still in the store.
func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string { func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string {
t.Helper() t.Helper()
@@ -1427,8 +1481,8 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
stale := time.Now().Add(-inUseGrace - time.Minute).Unix() stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1} a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1} b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
require.NoError(t, insertCache(db, a)) require.NoError(t, handler.insertCache(db, a))
require.NoError(t, insertCache(db, b)) require.NoError(t, handler.insertCache(db, b))
// Write the backing blobs so the dedup deletion has something to remove. // Write the backing blobs so the dedup deletion has something to remove.
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a"))) require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
_, err = handler.storage.Commit(a.ID, 1) _, err = handler.storage.Commit(a.ID, 1)
@@ -1522,7 +1576,10 @@ func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
} }
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api")) assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo"}`)) assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo","public_url":"http://runner.example","results":"https://gitea.example","insecure_tls":true}`))
cred, ok := handler.lookupCredential("via-internal-api")
require.True(t, ok)
assert.Equal(t, JobCredential{Repo: testRepo, PublicURL: "http://runner.example"}, cred)
assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api")) assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api"))
assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`)) assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api")) assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
+4 -1
View File
@@ -40,6 +40,7 @@ const (
blobUploadURLTTL = time.Hour blobUploadURLTTL = time.Hour
twirpInternal = "internal" twirpInternal = "internal"
twirpUnavailable = "unavailable"
twirpUnauthenticated = "unauthenticated" twirpUnauthenticated = "unauthenticated"
) )
@@ -111,7 +112,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
CreatedAt: now, CreatedAt: now,
UsedAt: now, UsedAt: now,
} }
if err := insertCache(db, cache); err != nil { if err := h.insertCache(db, cache); err != nil {
h.twirpError(w, r, twirpInternal, err) h.twirpError(w, r, twirpInternal, err)
return return
} }
@@ -261,6 +262,8 @@ func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string
switch code { switch code {
case twirpInternal: case twirpInternal:
status = http.StatusInternalServerError status = http.StatusInternalServerError
case twirpUnavailable:
status = http.StatusServiceUnavailable
case twirpUnauthenticated: case twirpUnauthenticated:
status = http.StatusUnauthorized status = http.StatusUnauthorized
} }
+1 -7
View File
@@ -17,9 +17,6 @@ import (
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin // Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients // the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
// this runner cannot patch need, docker buildx among them. // this runner cannot patch need, docker buildx among them.
//
// The instance to forward to travels with the job registration rather than with configuration, so
// a cache server shared between runners serves each of their instances.
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/" const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job // forwardOrNotFound is the router's fallback: the artifact service of the instance the job
@@ -81,9 +78,6 @@ func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL
return true return true
} }
scheme := r.Header.Get("X-Forwarded-Proto") scheme := r.Header.Get("X-Forwarded-Proto")
if base, err := url.Parse(cred.PublicURL); err == nil && base.Scheme != "" {
scheme = base.Scheme
}
if scheme == "" { if scheme == "" {
scheme = "http" scheme = "http"
} }
@@ -98,4 +92,4 @@ func mergeQuery(target, request string) string {
} }
// insecureTransport is shared, because a transport per request would pool no connections. // insecureTransport is shared, because a transport per request would pool no connections.
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so var insecureTransport = &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
+6 -6
View File
@@ -54,11 +54,11 @@ func TestFrontResultsService(t *testing.T) {
assert.Contains(t, body["msg"], "bearer") assert.Contains(t, body["msg"], "bearer")
assert.NotEmpty(t, body["msg"]) assert.NotEmpty(t, body["msg"])
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL, PublicURL: "https://cache.example"})() defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: selfSigned.URL, InsecureTLS: true})()
status, _ = post(rpc) status, _ = post(rpc)
require.Equal(t, http.StatusOK, status, "a registered https public URL onto an http instance must proxy, not redirect") require.Equal(t, http.StatusOK, status, "an http job reaching an https instance must proxy")
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from") assert.Equal(t, strings.TrimPrefix(selfSigned.URL, "https://"), gotHost, "Gitea must see the host it mints its URLs from")
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs") assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
assert.Equal(t, rpc, gotPath) assert.Equal(t, rpc, gotPath)
@@ -87,15 +87,15 @@ func TestFrontResultsService(t *testing.T) {
}, },
{ {
name: "a trusted https instance is reached by the job itself", name: "a trusted https instance is reached by the job itself",
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example"}, want: http.StatusTemporaryRedirect, cred: JobCredential{Results: selfSigned.URL}, forwardedProto: "https", want: http.StatusTemporaryRedirect,
}, },
{ {
name: "the job does not share this server's disregard for the certificate", name: "the job does not share this server's disregard for the certificate",
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example", InsecureTLS: true}, want: http.StatusOK, cred: JobCredential{Results: selfSigned.URL, InsecureTLS: true}, forwardedProto: "https", want: http.StatusOK,
}, },
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError}, {name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
{ {
name: "a runner too old to send its public URL leaves the terminator to say so", name: "a forwarded https scheme onto an http instance is proxied",
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK, cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
}, },
{ {
+326
View File
@@ -0,0 +1,326 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"bytes"
"cmp"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/json/v2"
"encoding/xml"
"errors"
"fmt"
"io"
"io/fs"
"maps"
"net/http"
"net/url"
"os"
"path"
"slices"
"strconv"
"strings"
"time"
)
// S3Options configures the bucket that stores completed cache entries.
type S3Options struct {
Endpoint string
Region string
Bucket string
Prefix string
PathStyle *bool // nil uses virtual-hosted requests on AWS and path-style ones elsewhere
AccessKey string
SecretKey string
}
type cacheStorage interface {
Exist(uint64) (bool, error)
Write(uint64, int64, io.Reader) error
WriteBlock(uint64, string, io.Reader) error
OrderBlocks(uint64, []string) error
Commit(uint64, int64) (int64, error)
Serve(http.ResponseWriter, *http.Request, uint64)
Remove(uint64) error
}
const s3PartSize = 5 << 30 // the largest part S3 accepts
// s3Storage stages uploads on disk and keeps completed entries and their metadata in a bucket.
type s3Storage struct {
*Storage
client *http.Client
bucketURL *url.URL
opts S3Options
}
type s3Part struct {
PartNumber int
ETag string
}
func newS3Storage(staging *Storage, opts S3Options) (*s3Storage, error) {
endpoint, err := url.Parse(opts.Endpoint)
if err != nil || endpoint.Host == "" || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
return nil, fmt.Errorf("invalid S3 endpoint %q", opts.Endpoint)
}
transport, _ := http.DefaultTransport.(*http.Transport)
transport = transport.Clone()
transport.ResponseHeaderTimeout = time.Minute // a stalled endpoint must not hold the store forever
pathStyle := !strings.HasSuffix(endpoint.Hostname(), ".amazonaws.com")
if opts.PathStyle != nil {
pathStyle = *opts.PathStyle
}
if pathStyle {
endpoint.Path = "/" + opts.Bucket
} else {
endpoint.Host = opts.Bucket + "." + endpoint.Host
endpoint.Path = "/"
}
opts.Region = cmp.Or(opts.Region, "us-east-1")
return &s3Storage{Storage: staging, client: &http.Client{Transport: transport}, bucketURL: endpoint, opts: opts}, nil
}
func (s *s3Storage) blobKey(id uint64) string {
return path.Join(s.opts.Prefix, "blobs", strconv.FormatUint(id, 10))
}
func (s *s3Storage) metadataDir() string {
return path.Join(s.opts.Prefix, "metadata") + "/"
}
func (s *s3Storage) metadataKey(id uint64) string {
return s.metadataDir() + strconv.FormatUint(id, 10) + ".json"
}
func (s *s3Storage) url(key string, query url.Values) *url.URL {
u := *s.bucketURL
u.Path = path.Join(u.Path, key)
u.RawPath = strings.NewReplacer("+", "%20", "%2F", "/").Replace(url.QueryEscape(u.Path)) // SigV4 encoding
u.RawQuery = strings.ReplaceAll(query.Encode(), "+", "%20")
return &u
}
// sign authorizes req with AWS Signature Version 4, leaving the payload unsigned so bodies can stream.
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
func (s *s3Storage) sign(req *http.Request, now time.Time) {
date := now.UTC().Format("20060102T150405Z")
req.Header.Set("X-Amz-Date", date)
if req.Header.Get("X-Amz-Content-Sha256") == "" {
req.Header.Set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD")
}
headers := map[string]string{"host": req.URL.Host}
for name, values := range req.Header {
if name = strings.ToLower(name); strings.HasPrefix(name, "x-amz-") {
headers[name] = strings.Join(values, ",")
}
}
names := slices.Sorted(maps.Keys(headers))
canonical := []string{req.Method, req.URL.EscapedPath(), req.URL.RawQuery}
for _, name := range names {
canonical = append(canonical, name+":"+headers[name])
}
canonical = append(canonical, "", strings.Join(names, ";"), req.Header.Get("X-Amz-Content-Sha256"))
scope := date[:8] + "/" + s.opts.Region + "/s3/aws4_request"
signature := []byte("AWS4" + s.opts.SecretKey)
for _, data := range []string{date[:8], s.opts.Region, "s3", "aws4_request", fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%x", date, scope, sha256.Sum256([]byte(strings.Join(canonical, "\n"))))} { // key derivation, the last round signs
mac := hmac.New(sha256.New, signature)
_, _ = mac.Write([]byte(data))
signature = mac.Sum(nil)
}
req.Header.Set("Authorization", fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s,SignedHeaders=%s,Signature=%x", s.opts.AccessKey, scope, strings.Join(names, ";"), signature))
}
// do returns the response to a signed request when it succeeded, fs.ErrNotExist for a missing key.
func (s *s3Storage) do(ctx context.Context, method, key string, query url.Values, header http.Header, body io.Reader) (*http.Response, error) {
var size int64
if sized, ok := body.(interface{ Size() int64 }); ok {
size = sized.Size()
}
if size == 0 {
body = nil
}
req, err := http.NewRequestWithContext(ctx, method, s.url(key, query).String(), body)
if err != nil {
return nil, err
}
req.ContentLength = size
maps.Copy(req.Header, header)
s.sign(req, time.Now())
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusMultipleChoices {
return resp, nil
}
defer drain(resp)
var failure struct{ Code, Message string }
_ = xml.NewDecoder(resp.Body).Decode(&failure)
if resp.StatusCode == http.StatusNotFound && (failure.Code == "NoSuchKey" || failure.Code == "" && method == http.MethodHead) {
return nil, fs.ErrNotExist
}
return nil, fmt.Errorf("S3 %s %s: %s", method, key, cmp.Or(strings.TrimSpace(failure.Code+" "+failure.Message), resp.Status))
}
func (s *s3Storage) call(ctx context.Context, method, key string, query url.Values, body io.Reader, result any) (http.Header, error) {
resp, err := s.do(ctx, method, key, query, nil, body)
if err != nil {
return nil, err
}
defer drain(resp)
if result == nil {
return resp.Header, nil
}
return resp.Header, xml.NewDecoder(resp.Body).Decode(result)
}
// drain reads a response to its end before closing it, so its connection gets reused.
func drain(resp *http.Response) {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
func (s *s3Storage) Exist(id uint64) (bool, error) {
_, err := s.call(context.Background(), http.MethodHead, s.blobKey(id), nil, nil, nil)
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return err == nil, err
}
func (s *s3Storage) Commit(id uint64, size int64) (int64, error) {
written, err := s.Storage.Commit(id, size)
if err != nil {
return 0, err
}
defer func() { _ = s.Storage.Remove(id) }()
file, err := os.Open(s.filename(id))
if err != nil {
return 0, err
}
defer file.Close()
if err := s.upload(context.Background(), s.blobKey(id), file, written); err != nil {
return 0, fmt.Errorf("upload cache to S3: %w", err)
}
return written, nil
}
// upload always goes in parts, the only way S3 takes an object over 5 GiB.
func (s *s3Storage) upload(ctx context.Context, key string, file io.ReaderAt, size int64) error {
var created struct {
UploadID string `xml:"UploadId"`
}
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploads": {""}}, nil, &created); err != nil {
return err
}
var completion struct {
XMLName xml.Name `xml:"CompleteMultipartUpload"`
Parts []s3Part `xml:"Part"`
}
for offset := int64(0); offset == 0 || offset < size; offset += s3PartSize {
number, length := len(completion.Parts)+1, min(s3PartSize, size-offset)
query := url.Values{"partNumber": {strconv.Itoa(number)}, "uploadId": {created.UploadID}}
header, err := s.call(ctx, http.MethodPut, key, query, io.NewSectionReader(file, offset, length), nil)
if err != nil {
return err
}
completion.Parts = append(completion.Parts, s3Part{number, header.Get("ETag")})
}
body, err := xml.Marshal(completion)
if err != nil {
return err
}
var result struct{ Code, Message string } // a completion can fail after its 200 status
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploadId": {created.UploadID}}, bytes.NewReader(body), &result); err != nil {
return err
}
if result.Code != "" {
return fmt.Errorf("S3 complete upload %s: %s %s", key, result.Code, result.Message)
}
return nil
}
func (s *s3Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
header := http.Header{}
if byteRange := r.Header.Get("Range"); byteRange != "" {
header.Set("Range", byteRange)
}
resp, err := s.do(r.Context(), http.MethodGet, s.blobKey(id), nil, header, nil)
if errors.Is(err, fs.ErrNotExist) {
http.NotFound(w, r)
return
} else if err != nil {
http.Error(w, "read cache from S3", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for _, name := range []string{"Content-Length", "Content-Range"} {
if value := resp.Header.Get(name); value != "" {
w.Header().Set(name, value)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// Remove unpublishes an entry before deleting its blob, so no runner imports it without its bytes.
func (s *s3Storage) Remove(id uint64) error {
for _, key := range []string{s.metadataKey(id), s.blobKey(id)} {
if _, err := s.call(context.Background(), http.MethodDelete, key, nil, nil, nil); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove %s from S3: %w", key, err)
}
}
return s.Storage.Remove(id)
}
func (s *s3Storage) publish(cache *Cache) error {
data, err := json.Marshal(cache)
if err != nil {
return err
}
if _, err := s.call(context.Background(), http.MethodPut, s.metadataKey(cache.ID), nil, bytes.NewReader(data), nil); err != nil {
return fmt.Errorf("publish cache to S3: %w", err)
}
return nil
}
func (s *s3Storage) published(ctx context.Context) (map[uint64]bool, error) {
ids := make(map[uint64]bool)
query := url.Values{"list-type": {"2"}, "prefix": {s.metadataDir()}}
for {
var page struct {
Contents []struct{ Key string }
IsTruncated bool
NextContinuationToken string
}
if _, err := s.call(ctx, http.MethodGet, "", query, nil, &page); err != nil {
return nil, fmt.Errorf("list S3 cache metadata: %w", err)
}
for _, object := range page.Contents {
if id, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimPrefix(object.Key, s.metadataDir()), ".json"), 10, 64); err == nil {
ids[id] = true
}
}
if !page.IsTruncated {
return ids, nil
}
query.Set("continuation-token", page.NextContinuationToken)
}
}
func (s *s3Storage) fetch(ctx context.Context, id uint64) (*Cache, error) {
resp, err := s.do(ctx, http.MethodGet, s.metadataKey(id), nil, nil, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
cache := &Cache{}
if err := json.UnmarshalRead(resp.Body, cache); err != nil {
return nil, fmt.Errorf("read S3 cache metadata %d: %w", id, err)
}
return cache, nil
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/johannesboyne/gofakes3"
"github.com/johannesboyne/gofakes3/backend/s3mem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHandler_S3SharesEntriesAndDropsExpiredOnes(t *testing.T) {
backend := s3mem.New()
require.NoError(t, backend.CreateBucket("cache"))
server := httptest.NewServer(gofakes3.New(backend).Server())
t.Cleanup(server.Close)
opts := Options{S3: &S3Options{Endpoint: server.URL, Bucket: "cache", Prefix: "runners", AccessKey: "key", SecretKey: "secret"}}
saver, restorer := startTestHandler(t, opts), startTestHandler(t, opts)
entry := map[string]any{"key": "deps", "version": "v1"}
finalized, _ := saveV2(t, saver, "deps", "v1", []byte("archive"))
require.Equal(t, true, finalized["ok"])
require.NoError(t, restorer.importMetadata(t.Context()))
found := v2Call(t, restorer, testClient, "GetCacheEntryDownloadURL", entry)
require.Equal(t, true, found["ok"])
assert.Equal(t, []byte("archive"), getURL(t, found["signed_download_url"].(string)))
require.NoError(t, backend.ForceDeleteBucket("cache"))
require.NoError(t, backend.CreateBucket("cache"))
require.NoError(t, restorer.importMetadata(t.Context()))
assert.Equal(t, true, v2Call(t, restorer, testClient, "CreateCacheEntry", entry)["ok"])
}
func TestS3StorageSignsLikeAWS(t *testing.T) {
storage := &s3Storage{opts: S3Options{Region: "us-east-1", AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}}
req := httptest.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", nil)
req.Header.Set("X-Amz-Content-Sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
storage.sign(req, time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC))
assert.Equal(t, "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date,Signature=34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7", req.Header.Get("Authorization"))
}
+23 -4
View File
@@ -9,11 +9,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
nethttp "net/http"
"os" "os"
"path" "path"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/internal/pkg/lock" "gitea.com/gitea/runner/internal/pkg/lock"
@@ -22,6 +24,8 @@ import (
"github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/storer" "github.com/go-git/go-git/v5/plumbing/storer"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/client"
"github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -38,6 +42,20 @@ var (
ErrShortRef = errors.New("short SHA references are not supported") ErrShortRef = errors.New("short SHA references are not supported")
) )
func init() {
httpClient := newHTTPClient(&nethttp.HTTP2Config{SendPingTimeout: 30 * time.Second})
client.InstallProtocol("http", httpClient)
client.InstallProtocol("https", httpClient)
}
// newHTTPClient pings idle HTTP/2 connections to drop stalled ones.
func newHTTPClient(h2 *nethttp.HTTP2Config) transport.Transport {
defaultTransport, _ := nethttp.DefaultTransport.(*nethttp.Transport)
httpTransport := defaultTransport.Clone()
httpTransport.HTTP2 = h2
return http.NewClient(&nethttp.Client{Transport: httpTransport})
}
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir. // AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel. // Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too, // Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
@@ -438,10 +456,11 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
return nil, err return nil, err
} }
} else { } else {
for _, refName := range []plumbing.ReferenceName{ refNames := []plumbing.ReferenceName{plumbing.NewBranchReferenceName(input.Ref), plumbing.NewTagReferenceName(input.Ref)}
plumbing.NewBranchReferenceName(input.Ref), if input.Ref == plumbing.HEAD.String() || strings.HasPrefix(input.Ref, "refs/") {
plumbing.NewTagReferenceName(input.Ref), refNames = []plumbing.ReferenceName{plumbing.ReferenceName(input.Ref)} // HEAD is the remote's default branch
} { }
for _, refName := range refNames {
shallowOpts := opts shallowOpts := opts
shallowOpts.Depth = input.Depth shallowOpts.Depth = input.Depth
shallowOpts.SingleBranch = true shallowOpts.SingleBranch = true
+24
View File
@@ -6,7 +6,9 @@ package git
import ( import (
"context" "context"
"crypto/tls"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -756,3 +758,25 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
t.Fatal("fetch ignored context cancellation") t.Fatal("fetch ignored context cancellation")
} }
} }
func TestNewGitCloneExecutorFailsOnSilentHTTP2Connection(t *testing.T) {
server := httptest.NewUnstartedServer(nil)
server.EnableHTTP2 = true
server.Config.TLSNextProto = map[string]func(*http.Server, *tls.Conn, http.Handler){
"h2": func(_ *http.Server, conn *tls.Conn, _ http.Handler) { _, _ = io.Copy(io.Discard, conn) },
}
server.StartTLS()
t.Cleanup(server.Close)
previous := gogitclient.Protocols["https"]
gogitclient.InstallProtocol("https", newHTTPClient(&http.HTTP2Config{SendPingTimeout: 50 * time.Millisecond, PingTimeout: 50 * time.Millisecond}))
t.Cleanup(func() { gogitclient.InstallProtocol("https", previous) })
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: server.URL + "/action", Ref: "main", Dir: filepath.Join(t.TempDir(), "action"), InsecureSkipTLS: true,
})(ctx)
require.Error(t, err)
require.NoError(t, ctx.Err())
}
+40 -34
View File
@@ -10,6 +10,11 @@ import (
"strings" "strings"
) )
type interfaceIP struct {
net.IP
net.Interface
}
// GetOutboundIP returns an outbound IP address of this machine. // GetOutboundIP returns an outbound IP address of this machine.
// It tries to access the internet and returns the local IP address of the connection. // It tries to access the internet and returns the local IP address of the connection.
// If the machine cannot access the internet, it returns a preferred IP address from network interfaces. // If the machine cannot access the internet, it returns a preferred IP address from network interfaces.
@@ -26,11 +31,7 @@ func GetOutboundIP() net.IP {
// So the machine cannot access the internet. Pick an IP address from network interfaces. // So the machine cannot access the internet. Pick an IP address from network interfaces.
if ifs, err := net.Interfaces(); err == nil { if ifs, err := net.Interfaces(); err == nil {
type IP struct { var ips []interfaceIP
net.IP
net.Interface
}
var ips []IP
for _, i := range ifs { for _, i := range ifs {
if addrs, err := i.Addrs(); err == nil { if addrs, err := i.Addrs(); err == nil {
for _, addr := range addrs { for _, addr := range addrs {
@@ -42,40 +43,45 @@ func GetOutboundIP() net.IP {
ip = v.IP ip = v.IP
} }
if ip.IsGlobalUnicast() { if ip.IsGlobalUnicast() {
ips = append(ips, IP{ip, i}) ips = append(ips, interfaceIP{ip, i})
} }
} }
} }
} }
if len(ips) > 1 { return preferredIP(ips)
sort.Slice(ips, func(i, j int) bool {
ifi := ips[i].Interface
ifj := ips[j].Interface
// ethernet is preferred
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
return vi
}
ipi := ips[i].IP
ipj := ips[j].IP
// IPv4 is preferred
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
return vi
}
// en0 is preferred to en1
if ifi.Name != ifj.Name {
return ifi.Name < ifj.Name
}
// fallback
return ipi.String() < ipj.String()
})
return ips[0].IP
}
} }
return nil return nil
} }
func preferredIP(ips []interfaceIP) net.IP {
if len(ips) == 0 {
return nil
}
sort.Slice(ips, func(i, j int) bool {
ifi := ips[i].Interface
ifj := ips[j].Interface
// ethernet is preferred
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
return vi
}
ipi := ips[i].IP
ipj := ips[j].IP
// IPv4 is preferred
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
return vi
}
// en0 is preferred to en1
if ifi.Name != ifj.Name {
return ifi.Name < ifj.Name
}
// fallback
return ipi.String() < ipj.String()
})
return ips[0].IP
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPreferredIP(t *testing.T) {
assert.Nil(t, preferredIP(nil))
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}}))
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{
{IP: net.ParseIP("192.0.2.1"), Name: "wlan0"},
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
}))
}
+15
View File
@@ -9,6 +9,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"path"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -73,6 +75,9 @@ const (
HealthUnhealthy = string(container.Unhealthy) HealthUnhealthy = string(container.Unhealthy)
) )
// marks what a runner creates for its jobs, telling its leftovers from another runner's
const runnerUUIDLabel = "com.gitea.runner.uuid"
// ErrContainerNotFound reports a container the daemon no longer knows. Its text is a // ErrContainerNotFound reports a container the daemon no longer knows. Its text is a
// fragment, missingContainerError composes it into the message every operation shares. // fragment, missingContainerError composes it into the message every operation shares.
var ErrContainerNotFound = errors.New("does not exist") var ErrContainerNotFound = errors.New("does not exist")
@@ -107,6 +112,16 @@ type Info struct {
Mounts map[string]string // container path to its source on the daemon Mounts map[string]string // container path to its source on the daemon
} }
// DaemonPath maps target to its daemon path through the nearest mount with a source, empty without one.
func (info *Info) DaemonPath(target string) string {
for dir := target; dir != "/" && dir != "."; dir = path.Dir(dir) {
if source := info.Mounts[dir]; source != "" {
return path.Join(source, strings.TrimPrefix(target, dir))
}
}
return ""
}
// Container for managing docker run containers // Container for managing docker run containers
type Container interface { type Container interface {
Create(capAdd, capDrop []string) common.Executor Create(capAdd, capDrop []string) common.Executor
+7 -1
View File
@@ -4,9 +4,10 @@
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)) //go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts.go with: // This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
// * appended with license information // * appended with license information
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec // * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
// * jsontext in place of json.Compact for the seccomp profile
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go // * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
// //
// docker/cli is licensed under the Apache License, Version 2.0. // docker/cli is licensed under the Apache License, Version 2.0.
@@ -152,6 +153,7 @@ type containerOptions struct {
runtime string runtime string
autoRemove bool autoRemove bool
init bool init bool
umask opts.UmaskOpt
annotations *opts.MapOpts annotations *opts.MapOpts
Image string Image string
@@ -326,6 +328,9 @@ func addFlags(flags *pflag.FlagSet) *containerOptions {
flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm") flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm")
flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use") flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use")
flags.StringVar(&copts.runtime, "runtime", "", "Runtime to use for this container") flags.StringVar(&copts.runtime, "runtime", "", "Runtime to use for this container")
flags.Var(&copts.umask, "umask", "Set umask for the container")
flags.SetAnnotation("umask", "version", []string{"1.56"})
flags.SetAnnotation("umask", "ostype", []string{"linux"})
flags.BoolVar(&copts.init, "init", false, "Run an init inside the container that forwards signals and reaps processes") flags.BoolVar(&copts.init, "init", false, "Run an init inside the container that forwards signals and reaps processes")
flags.SetAnnotation("init", "version", []string{"1.25"}) flags.SetAnnotation("init", "version", []string{"1.25"})
@@ -721,6 +726,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
MaskedPaths: maskedPaths, MaskedPaths: maskedPaths,
ReadonlyPaths: readonlyPaths, ReadonlyPaths: readonlyPaths,
Annotations: copts.annotations.GetAll(), Annotations: copts.annotations.GetAll(),
Umask: copts.umask.Value(),
} }
if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() { if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() {
+14 -1
View File
@@ -2,7 +2,7 @@
// Copyright 2022 The nektos/act Authors. All rights reserved. // Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts_test.go with: // This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts_test.go with:
// * appended with license information // * appended with license information
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter // * added tests for the locally changed parseDevice, validateDevice and invalidParameter
// //
@@ -167,6 +167,19 @@ func TestParseRunLinks(t *testing.T) {
} }
} }
func TestParseRunWithoutUmask(t *testing.T) {
_, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"})
assert.NilError(t, err)
assert.Assert(t, hostConfig.Umask == nil)
}
func TestParseRunUmask(t *testing.T) {
_, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"})
assert.NilError(t, err)
assert.Assert(t, hostConfig.Umask != nil)
assert.Equal(t, uint32(0o22), *hostConfig.Umask)
}
func TestParseRunAttach(t *testing.T) { func TestParseRunAttach(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
+2 -3
View File
@@ -46,14 +46,13 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
} }
defer cli.Close() defer cli.Close()
inspectImage, err := cli.ImageInspect(ctx, imageName) if _, err := cli.ImageInspect(ctx, imageName); cerrdefs.IsNotFound(err) {
if cerrdefs.IsNotFound(err) {
return false, nil return false, nil
} else if err != nil { } else if err != nil {
return false, err return false, err
} }
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{ if _, err = cli.ImageRemove(ctx, imageName, client.ImageRemoveOptions{
Force: force, Force: force,
PruneChildren: pruneChildren, PruneChildren: pruneChildren,
}); err != nil { }); err != nil {
+67 -13
View File
@@ -16,16 +16,14 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client" "github.com/moby/moby/client"
) )
const ( const (
networkCreateAttempts = 3 networkCreateAttempts = 3
networkCreateRetryDelay = time.Second networkCreateRetryDelay = time.Second
// marks the networks a runner creates for its jobs, so it can tell its own leftovers from
// those of another runner sharing the daemon
runnerUUIDLabel = "com.gitea.runner.uuid"
) )
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did // RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
@@ -33,17 +31,17 @@ const (
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool // had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
// until it is removed. Networks created after createdBefore are left alone, so a job starting // until it is removed. Networks created after createdBefore are left alone, so a job starting
// while this runs cannot lose the network it has created but not yet attached a container to. // while this runs cannot lose the network it has created but not yet attached a container to.
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error { func RemoveOrphanNetworks(ctx context.Context, runnerUUID, detach string, createdBefore time.Time) error {
cli, err := GetDockerClient(ctx) cli, err := GetDockerClient(ctx)
if err != nil { if err != nil {
return fmt.Errorf("failed to connect to the docker daemon: %w", err) return fmt.Errorf("failed to connect to the docker daemon: %w", err)
} }
defer cli.Close() defer cli.Close()
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore) return removeOrphanNetworks(ctx, cli, runnerUUID, detach, createdBefore)
} }
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error { func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID, detach string, createdBefore time.Time) error {
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{ networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID), Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
}) })
@@ -52,6 +50,7 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
} }
var errs []error var errs []error
networks:
for _, n := range networks.Items { for _, n := range networks.Items {
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{}) result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
if err != nil { if err != nil {
@@ -60,9 +59,18 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
} }
// the emptiness check, not the label, is what keeps a live job of another process // the emptiness check, not the label, is what keeps a live job of another process
// sharing this registration safe // sharing this registration safe
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) { if len(result.Network.Containers) > 1 || result.Network.Created.After(createdBefore) {
continue continue
} }
for containerID := range result.Network.Containers {
if detach == "" || !strings.HasPrefix(containerID, detach) {
continue networks
}
if err := disconnectNetwork(ctx, cli, n.ID, containerID); err != nil {
errs = append(errs, fmt.Errorf("failed to disconnect container from network %s: %w", n.Name, err))
continue networks
}
}
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil { if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
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))
continue continue
@@ -135,6 +143,50 @@ func isAddressPoolExhausted(err error) bool {
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
} }
func NewDockerNetworkConnectExecutor(networkName, container string) common.Executor {
return func(ctx context.Context) error {
cli, err := GetDockerClient(ctx)
if err != nil {
return err
}
defer cli.Close()
if err := connectNetwork(ctx, cli, networkName, container); err != nil {
common.Logger(ctx).Warnf("cannot connect %s to network %s, so jobs on it cannot reach the cache: %v", container, networkName, err)
}
return nil
}
}
func connectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
_, err := cli.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
Container: container,
EndpointConfig: &network.EndpointSettings{Aliases: []string{container}},
})
if err != nil && (strings.Contains(err.Error(), "already exists in network") || strings.Contains(err.Error(), "already connected")) {
return nil
}
return err
}
func NewDockerNetworkDisconnectExecutor(networkName, container string) common.Executor {
return func(ctx context.Context) error {
cli, err := GetDockerClient(ctx)
if err != nil {
return err
}
defer cli.Close()
return disconnectNetwork(ctx, cli, networkName, container)
}
}
func disconnectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
_, err := cli.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{Container: container, Force: true})
if cerrdefs.IsNotFound(err) || (err != nil && strings.Contains(err.Error(), "is not connected")) {
return nil
}
return err
}
func NewDockerNetworkRemoveExecutor(name string) common.Executor { func NewDockerNetworkRemoveExecutor(name string) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
cli, err := GetDockerClient(ctx) cli, err := GetDockerClient(ctx)
@@ -174,16 +226,17 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
} }
} }
func IsolatedNetwork(ctx context.Context, addr netip.Addr, jobNetwork string) (string, error) { // IsolatedCacheContainer returns the container holding addr, when jobs on jobNetwork cannot reach it there.
func IsolatedCacheContainer(ctx context.Context, addr netip.Addr, jobNetwork string) (string, error) {
cli, err := GetDockerClient(ctx) cli, err := GetDockerClient(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
defer cli.Close() defer cli.Close()
return isolatedNetwork(ctx, cli, addr, jobNetwork) return isolatedCacheContainer(ctx, cli, addr, jobNetwork)
} }
func isolatedNetwork(ctx context.Context, cli client.APIClient, addr netip.Addr, jobNetwork string) (string, error) { func isolatedCacheContainer(ctx context.Context, cli client.APIClient, addr netip.Addr, jobNetwork string) (string, error) {
if jobNetwork == "host" || strings.HasPrefix(jobNetwork, "container:") { if jobNetwork == "host" || strings.HasPrefix(jobNetwork, "container:") {
return "", nil return "", nil
} }
@@ -191,7 +244,7 @@ func isolatedNetwork(ctx context.Context, cli client.APIClient, addr netip.Addr,
if err != nil { if err != nil {
return "", err return "", err
} }
var holderID string var holderID, holderContainer string
for _, summary := range containers.Items { for _, summary := range containers.Items {
if summary.NetworkSettings == nil { if summary.NetworkSettings == nil {
continue continue
@@ -199,6 +252,7 @@ func isolatedNetwork(ctx context.Context, cli client.APIClient, addr netip.Addr,
for _, endpoint := range summary.NetworkSettings.Networks { for _, endpoint := range summary.NetworkSettings.Networks {
if endpoint != nil && (endpoint.IPAddress == addr || endpoint.GlobalIPv6Address == addr) { if endpoint != nil && (endpoint.IPAddress == addr || endpoint.GlobalIPv6Address == addr) {
holderID = endpoint.NetworkID holderID = endpoint.NetworkID
holderContainer = summary.ID[:12]
} }
} }
} }
@@ -215,5 +269,5 @@ func isolatedNetwork(ctx context.Context, cli client.APIClient, addr netip.Addr,
return "", err return "", err
} }
} }
return holder.Network.Name, nil return holderContainer, nil
} }
+58 -9
View File
@@ -18,11 +18,12 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestIsolatedNetwork(t *testing.T) { func TestIsolatedCacheContainer(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20") cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, { client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
ID: "0123456789abcdef",
NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{ NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{
"compose": {NetworkID: "c0ffee", IPAddress: cache}, "compose": {NetworkID: "c0ffee", IPAddress: cache},
"lan": {NetworkID: "beef", IPAddress: lan}, "lan": {NetworkID: "beef", IPAddress: lan},
@@ -38,12 +39,12 @@ func TestIsolatedNetwork(t *testing.T) {
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: inspected}}, nil) Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: inspected}}, nil)
} }
check := func(addr netip.Addr, jobNetwork, want string) { check := func(addr netip.Addr, jobNetwork, want string) {
got, err := isolatedNetwork(ctx, client, addr, jobNetwork) got, err := isolatedCacheContainer(ctx, client, addr, jobNetwork)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, want, got, jobNetwork) assert.Equal(t, want, got, jobNetwork)
} }
check(cache, "", "compose") check(cache, "", "0123456789ab")
check(cache, "jobs", "compose") check(cache, "jobs", "0123456789ab")
check(cache, "c0f", "") check(cache, "c0f", "")
check(cache, "host", "") check(cache, "host", "")
check(lan, "", "") check(lan, "", "")
@@ -56,9 +57,44 @@ func TestIsAddressPoolExhausted(t *testing.T) {
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network"))) assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
} }
// Of this runner's networks, only the ones nothing is attached to and old enough to predate func (m *mockDockerClient) NetworkConnect(ctx context.Context, id string, opts mobyclient.NetworkConnectOptions) (mobyclient.NetworkConnectResult, error) {
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
// test on its own, since testify has no expectation to match it against. }
func (m *mockDockerClient) NetworkDisconnect(ctx context.Context, id string, opts mobyclient.NetworkDisconnectOptions) (mobyclient.NetworkDisconnectResult, error) {
return mobyclient.NetworkDisconnectResult{}, m.Called(ctx, id, opts).Error(0)
}
func TestConnectAndDisconnectNetwork(t *testing.T) {
ctx := context.Background()
connect := func(answer error) error {
client := &mockDockerClient{}
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
Container: "0123456789ab",
EndpointConfig: &network.EndpointSettings{Aliases: []string{"0123456789ab"}},
}).Return(answer).Once()
return connectNetwork(ctx, client, "jobs", "0123456789ab")
}
require.NoError(t, connect(nil))
require.NoError(t, connect(errors.New("endpoint with name runner already exists in network jobs")))
require.NoError(t, connect(errors.New("container runner already connected to network jobs")))
require.ErrorIs(t, connect(context.Canceled), context.Canceled)
disconnect := func(answer error) error {
client := &mockDockerClient{}
client.On("NetworkDisconnect", ctx, "jobs", mobyclient.NetworkDisconnectOptions{
Container: "0123456789ab", Force: true,
}).Return(answer).Once()
return disconnectNetwork(ctx, client, "jobs", "0123456789ab")
}
require.NoError(t, disconnect(cerrdefs.ErrNotFound))
require.NoError(t, disconnect(errors.New("container runner is not connected to the network jobs")))
require.ErrorIs(t, disconnect(context.Canceled), context.Canceled)
}
// Of this runner's networks, only the ones nothing but its own cache container is attached to
// and old enough to predate any job now starting are the runner's to reclaim. An unexpected
// NetworkRemove fails the test on its own, since testify has no expectation to match it against.
func TestRemoveOrphanNetworks(t *testing.T) { func TestRemoveOrphanNetworks(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC) cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
@@ -69,16 +105,29 @@ func TestRemoveOrphanNetworks(t *testing.T) {
{ID: "orphan"}, {ID: "orphan"},
{ID: "busy"}, {ID: "busy"},
{ID: "starting"}, {ID: "starting"},
{ID: "cached"},
{ID: "shared"},
}}, nil) }}, nil)
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}). client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
Return(mobyclient.NetworkInspectResult{}, nil) Return(mobyclient.NetworkInspectResult{}, nil)
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}). client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil) Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}). client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil) Return(mobyclient.NetworkInspectResult{Network: network.Inspect{
Network: network.Network{Created: cutoff.Add(time.Second)},
Containers: map[string]network.EndpointResource{"0123456789abcdef": {}},
}}, nil)
client.On("NetworkInspect", ctx, "cached", mobyclient.NetworkInspectOptions{}).
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}}}}, nil)
client.On("NetworkInspect", ctx, "shared", mobyclient.NetworkInspectOptions{}).
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}, "job": {}}}}, nil)
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}). client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
Return(mobyclient.NetworkRemoveResult{}, nil) Return(mobyclient.NetworkRemoveResult{}, nil)
client.On("NetworkDisconnect", ctx, "cached", mobyclient.NetworkDisconnectOptions{Container: "0123456789abcdef", Force: true}).
Return(nil).Once()
client.On("NetworkRemove", ctx, "cached", mobyclient.NetworkRemoveOptions{}).
Return(mobyclient.NetworkRemoveResult{}, nil).Once()
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff)) require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", "0123456789ab", cutoff))
client.AssertExpectations(t) client.AssertExpectations(t)
} }
+26 -21
View File
@@ -66,15 +66,11 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 { if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 {
return nil return nil
} }
dir, err := filepath.Abs(os.TempDir()) dir, daemonDir := runnerContainerWorkdir(probeCtx, cli)
if err != nil { seen := daemonDir != ""
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err) if !seen {
return nil if dir, err = filepath.Abs(os.TempDir()); err == nil {
} daemonDir = 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) seen, err = daemonSeesDir(probeCtx, cli, dir, daemonDir)
} }
} }
@@ -109,18 +105,16 @@ func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir,
if err != nil { if err != nil {
return "", "" return "", ""
} }
destination := "" if daemonDir = containerInfoFromInspect(self.Container).DaemonPath(workdir); daemonDir == "" {
for _, point := range self.Container.Mounts { return "", ""
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) if seen, _ := containerSeesMarker(ctx, cli, workdir, self.Container.ID, workdir); !seen { // the hostname may name another container
} return "", ""
} }
return workdir, daemonDir return workdir, daemonDir
} }
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir by their path in daemonDir, func containerSeesMarker(ctx context.Context, cli client.APIClient, dir, id, containerDir string) (bool, error) {
// which is what a job's proxy socket mounted from there needs.
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
@@ -133,6 +127,16 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
if err := marker.Close(); err != nil { if err := marker.Close(); err != nil {
return false, err return false, err
} }
_, err = cli.ContainerStatPath(ctx, id, client.ContainerStatPathOptions{Path: path.Join(containerDir, filepath.Base(marker.Name()))})
if cerrdefs.IsNotFound(err) {
return false, nil
}
return err == nil, err
}
// 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.
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir string) (bool, error) {
images, err := cli.ImageList(ctx, client.ImageListOptions{}) images, err := cli.ImageList(ctx, client.ImageListOptions{})
if err != nil { if err != nil {
return false, err return false, err
@@ -143,7 +147,7 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
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: filepath.Join(daemonDir, filepath.Base(marker.Name())), Target: "/gitea-runner-probe", ReadOnly: true}, {Type: mount.TypeBind, Source: daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}, // not the marker itself, podman creates a missing bind source where docker rejects it
}}, }},
}) })
if cerrdefs.IsInvalidArgument(err) { if cerrdefs.IsInvalidArgument(err) {
@@ -152,12 +156,13 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
if err != nil { if err != nil {
return false, err return false, err
} }
seen, err := containerSeesMarker(ctx, cli, dir, created.ID, "/gitea-runner-probe")
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout) cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
defer cancel() defer cancel()
if _, err := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); err != nil { if _, removeErr := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
return false, fmt.Errorf("removing the docker proxy probe container failed: %w", err) return false, fmt.Errorf("removing the docker proxy probe container failed: %w", removeErr)
} }
return true, nil return seen, err
} }
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it. // StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
+66 -21
View File
@@ -321,6 +321,9 @@ func TestDockerProxyWithDaemon(t *testing.T) {
seen, err := daemonSeesDir(ctx, direct, dir, 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)
seen, err = daemonSeesDir(ctx, direct, dir, shortTempDir(t))
require.NoError(t, err)
assert.False(t, seen)
job := "proxy-test-" + strconv.FormatInt(time.Now().UnixNano(), 36) job := "proxy-test-" + strconv.FormatInt(time.Now().UnixNano(), 36)
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job) proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
@@ -425,6 +428,12 @@ type probeClient struct {
mobyclient.APIClient mobyclient.APIClient
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
stat func(string) error
mounts []container.MountPoint
}
func (c *probeClient) ContainerInspect(context.Context, string, mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
return mobyclient.ContainerInspectResult{Container: container.InspectResponse{Mounts: c.mounts}}, nil
} }
func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) { func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) {
@@ -439,43 +448,47 @@ func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyc
return c.remove(ctx, id, opts) return c.remove(ctx, id, opts)
} }
func (c *probeClient) ContainerStatPath(_ context.Context, _ string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
return mobyclient.ContainerStatPathResult{}, c.stat(opts.Path)
}
func TestDaemonSeesDir(t *testing.T) { func TestDaemonSeesDir(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
markers := make(map[string]bool) markers := make(map[string]bool)
for _, testCase := range []struct { for _, testCase := range []struct {
name string name string
private bool daemonDir string
removeErr error removeErr error
seen bool
removed bool
}{ }{
{name: "cleanup after cancellation"}, {name: "cleanup after cancellation", daemonDir: dir, seen: true, removed: true},
{name: "private filesystem with stale marker", private: true}, {name: "daemon without the directory", daemonDir: filepath.Join(dir, "missing")},
{name: "cleanup error after cancellation", removeErr: errors.New("cleanup failed")}, {name: "daemon with a different directory at that path", daemonDir: t.TempDir(), removed: true},
{name: "cleanup error after cancellation", daemonDir: dir, removeErr: errors.New("cleanup failed"), removed: true},
} { } {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
daemonDir := dir
if testCase.private {
daemonDir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(daemonDir, "probe"), []byte("stale"), 0o600))
}
removed := false removed := false
cli := &probeClient{ cli := &probeClient{
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) { create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
require.Len(t, opts.HostConfig.Mounts, 1) assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts)
marker := opts.HostConfig.Mounts[0].Source
assert.Equal(t, mount.Mount{Type: mount.TypeBind, Source: marker, Target: "/gitea-runner-probe", ReadOnly: true}, opts.HostConfig.Mounts[0])
assert.False(t, markers[marker])
markers[marker] = true
info, err := os.Stat(marker)
require.NoError(t, err)
assert.True(t, info.Mode().IsRegular())
cancel() cancel()
if _, err := os.Stat(filepath.Join(daemonDir, filepath.Base(marker))); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(testCase.daemonDir); err != nil {
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
} }
return mobyclient.ContainerCreateResult{ID: "probe"}, nil return mobyclient.ContainerCreateResult{ID: "probe"}, nil
}, },
stat: func(path string) error {
marker := strings.TrimPrefix(path, "/gitea-runner-probe/")
assert.False(t, markers[marker])
markers[marker] = true
if _, err := os.Stat(filepath.Join(testCase.daemonDir, marker)); err != nil {
return cerrdefs.ErrNotFound
}
return nil
},
remove: func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) { remove: func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
removed = true removed = true
assert.Equal(t, "probe", id) assert.Equal(t, "probe", id)
@@ -486,13 +499,45 @@ func TestDaemonSeesDir(t *testing.T) {
return mobyclient.ContainerRemoveResult{}, testCase.removeErr return mobyclient.ContainerRemoveResult{}, testCase.removeErr
}, },
} }
seen, err := daemonSeesDir(ctx, cli, dir, dir) seen, err := daemonSeesDir(ctx, cli, dir, testCase.daemonDir)
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.seen, seen)
assert.Equal(t, !testCase.private, removed) assert.Equal(t, testCase.removed, removed)
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, entries) assert.Empty(t, entries)
}) })
} }
} }
func TestRunnerContainerWorkdir(t *testing.T) {
workdir := t.TempDir()
t.Chdir(workdir)
volume := container.MountPoint{Type: mount.TypeVolume, Source: "/daemon/_data", Destination: filepath.Dir(workdir)}
for _, testCase := range []struct {
name string
mount container.MountPoint
foreign bool
daemonDir string
}{
{name: "volume above the working directory", mount: volume, daemonDir: filepath.Join(volume.Source, filepath.Base(workdir))},
{name: "tmpfs", mount: container.MountPoint{Type: mount.TypeTmpfs, Destination: volume.Destination}},
{name: "hostname of another container", mount: volume, foreign: true},
} {
cli := &probeClient{
mounts: []container.MountPoint{testCase.mount},
stat: func(path string) error {
if _, err := os.Stat(path); err != nil || testCase.foreign {
return cerrdefs.ErrNotFound
}
return nil
},
}
dir, daemonDir := runnerContainerWorkdir(t.Context(), cli)
assert.Equal(t, testCase.daemonDir, daemonDir, testCase.name)
assert.Equal(t, testCase.daemonDir != "", dir == workdir, testCase.name)
}
entries, err := os.ReadDir(workdir)
require.NoError(t, err)
assert.Empty(t, entries)
}
+27 -77
View File
@@ -7,7 +7,6 @@
package container package container
import ( import (
"archive/tar"
"bytes" "bytes"
"context" "context"
"errors" "errors"
@@ -25,16 +24,12 @@ import (
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector"
"dario.cat/mergo" "dario.cat/mergo"
"github.com/bmatcuk/doublestar/v4"
cerrdefs "github.com/containerd/errdefs" cerrdefs "github.com/containerd/errdefs"
"github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/connhelper" "github.com/docker/cli/cli/connhelper"
"github.com/go-git/go-billy/v5/helper/polyfill"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/gobwas/glob"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
@@ -593,7 +588,8 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
// If the `privileged` config has been disabled, `copts.privileged` need to be forced to false, // If the `privileged` config has been disabled, `copts.privileged` need to be forced to false,
// even if the user specifies `--privileged` in the options string. // even if the user specifies `--privileged` in the options string.
if !hostConfig.Privileged { if !hostConfig.Privileged && copts.privileged {
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", "--privileged")
copts.privileged = false copts.privileged = false
} }
@@ -629,6 +625,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig) logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
cr.sanitizeConfig(ctx, nil, containerConfig.HostConfig) // a dropped volume must not displace the runner's own
overlayVolumes(hostConfig, containerConfig.HostConfig) overlayVolumes(hostConfig, containerConfig.HostConfig)
binds := hostConfig.Binds binds := hostConfig.Binds
mounts := hostConfig.Mounts mounts := hostConfig.Mounts
@@ -980,42 +977,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
logger.Error(err) logger.Error(err)
} }
}(tarFile) }(tarFile)
tw := tar.NewWriter(tarFile) if err := writeDirTar(ctx, tarFile, dstPath, srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID); err != nil {
srcPrefix := filepath.Dir(srcPath)
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
srcPrefix += string(filepath.Separator)
}
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
var ignorer gitignore.Matcher
if useGitIgnore {
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
if err != nil {
logger.Debugf("Error loading .gitignore: %v", err)
}
ignorer = gitignore.NewMatcher(ps)
}
fc := &filecollector.FileCollector{
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
SkipGitDir: skipGitDir,
Handler: &filecollector.TarCollector{
TarWriter: tw,
UID: cr.UID,
GID: cr.GID,
DstDir: dstPath[1:],
},
}
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
if err != nil {
return err
}
if err := tw.Close(); err != nil {
return err return err
} }
@@ -1040,30 +1002,12 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
if cr.id == "" { if cr.id == "" {
return cr.missingContainerError("copy to %s", dstPath) return cr.missingContainerError("copy to %s", dstPath)
} }
logger := common.Logger(ctx)
var buf bytes.Buffer var buf bytes.Buffer
tw := tar.NewWriter(&buf) if err := writeFilesTar(ctx, &buf, cr.UID, cr.GID, files...); err != nil {
for _, file := range files {
logger.Debugf("Writing entry to tarball %s len:%d", file.Name, len(file.Body))
hdr := &tar.Header{
Name: file.Name,
Mode: file.Mode,
Size: int64(len(file.Body)),
Uid: cr.UID,
Gid: cr.GID,
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write([]byte(file.Body)); err != nil {
return err
}
}
if err := tw.Close(); err != nil {
return err return err
} }
logger.Debugf("Extracting content to '%s'", dstPath) common.Logger(ctx).Debugf("Extracting content to '%s'", dstPath)
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{ _, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: dstPath, DestinationPath: dstPath,
Content: &buf, Content: &buf,
@@ -1261,6 +1205,12 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
return config, hostConfig return config, hostConfig
} }
func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string {
cr := &containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}
_, hostConfig := cr.sanitizeConfig(ctx, nil, &container.HostConfig{Binds: binds})
return hostConfig.Binds
}
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed. // bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
func bindTarget(bind string) string { func bindTarget(bind string) string {
parsed, err := loader.ParseVolume(bind) parsed, err := loader.ParseVolume(bind)
@@ -1291,15 +1241,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
type validVolumeMatcher struct { type validVolumeMatcher struct {
allowAll bool allowAll bool
named []*glob.Pattern named []string
host []*glob.Pattern host []string
} }
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher { func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
logger := common.Logger(ctx) logger := common.Logger(ctx)
ret := validVolumeMatcher{ ret := validVolumeMatcher{
named: make([]*glob.Pattern, 0, len(validVolumes)), named: make([]string, 0, len(validVolumes)),
host: make([]*glob.Pattern, 0, len(validVolumes)), host: make([]string, 0, len(validVolumes)),
} }
for _, v := range validVolumes { for _, v := range validVolumes {
@@ -1308,10 +1258,10 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
continue continue
} }
if !isHostVolumePattern(v) { if !isHostVolumePattern(v) {
if g, err := glob.Compile(v); err != nil { if doublestar.ValidatePattern(v) {
logger.Errorf("create glob from %s error: %v", v, err) ret.named = append(ret.named, v)
} else { } else {
ret.named = append(ret.named, g) logger.Errorf("invalid volume pattern %s", v)
} }
continue continue
} }
@@ -1320,10 +1270,10 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
logger.Errorf("normalize volume pattern %s error: %v", v, err) logger.Errorf("normalize volume pattern %s error: %v", v, err)
continue continue
} }
if g, err := glob.Compile(normalized); err != nil { if doublestar.ValidatePathPattern(normalized) {
logger.Errorf("create glob from %s error: %v", normalized, err) ret.host = append(ret.host, normalized)
} else { } else {
ret.host = append(ret.host, g) logger.Errorf("invalid volume pattern %s", normalized)
} }
} }
@@ -1339,15 +1289,15 @@ func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool {
if err != nil { if err != nil {
return false return false
} }
for _, g := range m.host { for _, pattern := range m.host {
if g.Match(normalized) { if doublestar.PathMatchUnvalidated(pattern, normalized) {
return true return true
} }
} }
return false return false
} }
for _, g := range m.named { for _, pattern := range m.named {
if g.Match(source) { if doublestar.MatchUnvalidated(pattern, source) {
return true return true
} }
} }
+10 -2
View File
@@ -560,11 +560,14 @@ func TestCheckVolumes(t *testing.T) {
"shared_volume", "shared_volume",
"/home/test/data", "/home/test/data",
"/etc/conf.d/*.json", "/etc/conf.d/*.json",
"/opt/**",
}, },
binds: []string{ binds: []string{
"shared_volume:/shared_volume", "shared_volume:/shared_volume",
"/home/test/data:/test_data", "/home/test/data:/test_data",
"/etc/conf.d/base.json:/config/base.json", "/etc/conf.d/base.json:/config/base.json",
"/etc/conf.d/sub/nested.json:/config/nested.json",
"/opt/app/cache:/cache",
"sql_data:/sql_data", "sql_data:/sql_data",
"/secrets/keys:/keys", "/secrets/keys:/keys",
}, },
@@ -572,6 +575,7 @@ func TestCheckVolumes(t *testing.T) {
"shared_volume:/shared_volume", "shared_volume:/shared_volume",
"/home/test/data:/test_data", "/home/test/data:/test_data",
"/etc/conf.d/base.json:/config/base.json", "/etc/conf.d/base.json:/config/base.json",
"/opt/app/cache:/cache",
}, },
}, },
} }
@@ -678,6 +682,7 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privilege
WorkflowOptions: workflowOptions, WorkflowOptions: workflowOptions,
NetworkMode: "bridge", NetworkMode: "bridge",
UsernsMode: "private", UsernsMode: "private",
ValidVolumes: []string{"**"},
}} }}
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{ _, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
@@ -840,8 +845,10 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
ctx := common.WithLogger(context.Background(), logger) ctx := common.WithLogger(context.Background(), logger)
cr := &containerReference{ cr := &containerReference{
input: &NewContainerInput{ input: &NewContainerInput{
NetworkMode: "bridge", NetworkMode: "bridge",
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache", RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
WorkflowOptions: "--volume /unapproved:/var/run/docker.sock",
ValidVolumes: []string{"/host/tools"},
}, },
} }
@@ -863,6 +870,7 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", "")) assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
assert.Zero(t, warnings("", "--shm-size 1g")) assert.Zero(t, warnings("", "--shm-size 1g"))
assert.Equal(t, 1, warnings("--network host", "")) assert.Equal(t, 1, warnings("--network host", ""))
assert.Equal(t, 1, warnings("", "--privileged"))
} }
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) { func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
+18 -2
View File
@@ -18,7 +18,7 @@ import (
"github.com/moby/moby/api/types/system" "github.com/moby/moby/api/types/system"
) )
func IsolatedNetwork(context.Context, netip.Addr, string) (string, error) { func IsolatedCacheContainer(context.Context, netip.Addr, string) (string, error) {
return "", nil return "", nil
} }
@@ -91,10 +91,26 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
} }
} }
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error { func NewDockerNetworkConnectExecutor(_, _ string) common.Executor {
return func(_ context.Context) error {
return nil
}
}
func NewDockerNetworkDisconnectExecutor(_, _ string) common.Executor {
return func(_ context.Context) error {
return nil
}
}
func RemoveOrphanNetworks(_ context.Context, _, _ string, _ time.Time) error {
return nil return nil
} }
func SanitizeBinds(_ context.Context, _, binds []string) []string {
return binds
}
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error { func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
return nil return nil
} }
+576
View File
@@ -0,0 +1,576 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"bufio"
"bytes"
"cmp"
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"os"
"path"
"regexp"
"slices"
"strings"
"time"
"gitea.com/gitea/runner/act/common"
"github.com/kballard/go-shellquote"
"github.com/spf13/pflag"
)
// KubernetesOptions configures the pod a job runs in.
type KubernetesOptions struct {
Kubeconfig string
Namespace string
PodTemplates []map[string]any // merged in order, a later one winning
MaxLifetime time.Duration
ForcePull bool
RunnerUUID string
}
// KubernetesPod runs a job container and its service containers as one pod.
type KubernetesPod struct {
options KubernetesOptions
client *kubeClient
name, secretName string
job *kubernetesContainer
services []*kubernetesContainer
arch, path string
}
type kubernetesContainer struct {
LinuxContainerEnvironmentExtensions
pod *KubernetesPod
id string // the workflow's service id, name is its DNS label form
name string
input *NewContainerInput
probe map[string]any
}
type containerStatus struct {
Name string
Ready bool
State struct {
Waiting *struct{ Reason, Message string }
Running *struct{}
Terminated *struct {
ExitCode int
Message string
}
}
}
type podStatus struct {
Spec struct{ Containers []struct{} }
Status struct {
Phase, Reason, Message string
Conditions []struct{ Type, Status, Message string }
InitContainerStatuses, ContainerStatuses []containerStatus
}
}
func NewKubernetesPod(options KubernetesOptions) (*KubernetesPod, error) {
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
if err != nil {
return nil, fmt.Errorf("connect to Kubernetes: %w", err)
}
return &KubernetesPod{options: options, client: client}, nil
}
// JobContainer returns the container of the pod the job's steps run in.
func (p *KubernetesPod) JobContainer(input *NewContainerInput) ExecutionsEnvironment {
p.job = &kubernetesContainer{pod: p, name: "job", input: input}
return p.job
}
var invalidLabelChars = regexp.MustCompile(`[^a-z0-9-]+`)
// KubernetesServiceName returns the container name and hostname of service id, made a DNS label as both must be.
func KubernetesServiceName(id string) string {
name := strings.Trim(invalidLabelChars.ReplaceAllString(strings.ToLower(id), "-"), "-")
return strings.TrimRight(name[:min(63, len(name))], "-")
}
// ServiceContainer returns a container of the pod the job reaches at hostname KubernetesServiceName(id).
func (p *KubernetesPod) ServiceContainer(id string, input *NewContainerInput) ExecutionsEnvironment {
service := &kubernetesContainer{pod: p, id: id, name: KubernetesServiceName(id), input: input}
p.services = append(p.services, service)
return service
}
func (p *KubernetesPod) create(ctx context.Context) error {
prefix := strings.Trim(strings.ToLower(p.job.input.Name), "-")
prefix = cmp.Or(strings.TrimRight(prefix[:min(40, len(prefix))], "-"), "gitea-runner") + "-"
name := prefix + strings.ToLower(rand.Text()[:8]) // chosen here, so cleanup knows it even when a create response is lost
labels := map[string]any{"app.kubernetes.io/managed-by": "gitea-runner"}
if p.options.RunnerUUID != "" {
labels[runnerUUIDLabel] = p.options.RunnerUUID
}
metadata := map[string]any{"name": name, "labels": labels}
if hostname, err := os.Hostname(); p.client.inCluster && err == nil {
var runner struct{ Metadata struct{ Name, UID string } }
if err := p.client.do(ctx, http.MethodGet, p.client.path("pods/"+hostname), nil, &runner); err == nil {
metadata["ownerReferences"] = []any{map[string]any{"apiVersion": "v1", "kind": "Pod", "name": runner.Metadata.Name, "uid": runner.Metadata.UID}}
} else {
common.Logger(ctx).Debugf("The runner is not pod %s, job pods get no owner: %v", hostname, err)
}
}
secretData := map[string]string{}
manifest, err := p.manifest(name, secretData, metadata)
if err != nil {
return err
}
for _, service := range p.services {
if !strings.EqualFold(service.id, service.name) {
common.Logger(ctx).Warnf("Service %s is reachable as %s or localhost, as a Kubernetes hostname cannot be %q", service.id, service.name, service.id)
}
}
if len(secretData) > 0 {
p.secretName = name // named like the pod, RemoveOrphanKubernetesResources finds it by that
if err := p.client.do(ctx, http.MethodPost, p.client.path("secrets"), map[string]any{"metadata": metadata, "stringData": secretData}, nil); err != nil {
return fmt.Errorf("create secret: %w", err)
}
}
p.name = name
if err := p.client.do(ctx, http.MethodPost, p.client.path("pods"), manifest, nil); err != nil {
return fmt.Errorf("create pod: %w", err)
}
return nil
}
func (p *KubernetesPod) manifest(secret string, secretData map[string]string, metadata map[string]any) (any, error) {
template := map[string]any{"spec": map[string]any{
"automountServiceAccountToken": false,
"enableServiceLinks": false,
"terminationGracePeriodSeconds": 0, // the job container's tail ignores SIGTERM
}}
for _, layer := range p.options.PodTemplates {
template, _ = mergeManifest(template, layer, "").(map[string]any)
}
templateSpec, _ := template["spec"].(map[string]any)
templateContainers, _ := templateSpec["containers"].([]any)
templateVolumes, _ := templateSpec["volumes"].([]any)
var serviceTemplate any
if index := slices.IndexFunc(templateContainers, func(container any) bool { return manifestField(container, "name") == "$services" }); index >= 0 {
serviceTemplate = templateContainers[index]
templateSpec["containers"] = slices.Delete(slices.Clone(templateContainers), index, index+1)
}
var containers, hostnames, volumes []any
names := map[string]bool{}
for _, container := range templateContainers {
names[manifestField(container, "name")] = true
}
for _, c := range append([]*kubernetesContainer{p.job}, p.services...) {
if c != p.job && (c.name == "" || names[c.name]) {
return nil, fmt.Errorf("service %s maps to container %q, which is empty or named like another container of the pod", c.id, c.name)
}
names[c.name] = true
spec, err := c.spec(secret, secretData)
if err != nil {
return nil, err
}
if c == p.job {
containers = append(containers, spec)
continue
}
containers = append(containers, mergeManifest(serviceTemplate, spec, ""))
hostnames = append(hostnames, c.name)
}
for _, name := range []string{"workspace", "act"} {
if !slices.ContainsFunc(templateVolumes, func(volume any) bool { return manifestField(volume, "name") == name }) {
volumes = append(volumes, map[string]any{"name": name, "emptyDir": map[string]any{}})
}
}
spec := map[string]any{"restartPolicy": "Never", "containers": containers, "volumes": volumes}
if len(hostnames) > 0 {
spec["hostAliases"] = []any{map[string]any{"ip": "127.0.0.1", "hostnames": hostnames}}
}
if p.options.MaxLifetime > 0 {
spec["activeDeadlineSeconds"] = max(1, int64(p.options.MaxLifetime.Seconds()))
}
return mergeManifest(template, map[string]any{"apiVersion": "v1", "kind": "Pod", "metadata": metadata, "spec": spec}, ""), nil
}
// mergeManifest merges over onto base like a strategic merge patch: maps key by key, map list items by name or volumeMounts by mountPath, env entries, volumes and other values replaced.
func mergeManifest(base, over any, field string) any {
switch over := over.(type) {
case map[string]any:
merged := map[string]any{}
if base, ok := base.(map[string]any); ok {
maps.Copy(merged, base)
}
for key, value := range over {
merged[key] = mergeManifest(merged[key], value, key)
}
return merged
case []any:
key := cmp.Or(map[string]string{"volumeMounts": "mountPath"}[field], "name")
baseList, _ := base.([]any)
merged := slices.Clone(baseList)
for _, item := range over {
if _, ok := item.(map[string]any); !ok {
return over
}
name := manifestField(item, key)
index := slices.IndexFunc(merged, func(existing any) bool { return name != "" && manifestField(existing, key) == name })
switch {
case index < 0:
merged = append(merged, item)
case field == "env" || field == "volumes": // each holds one source, merging two makes a pod the API rejects
merged[index] = item
default:
merged[index] = mergeManifest(merged[index], item, "")
}
}
return merged
}
return over
}
func manifestField(item any, key string) string {
fields, _ := item.(map[string]any)
value, _ := fields[key].(string)
return value
}
func (c *kubernetesContainer) spec(secret string, secretData map[string]string) (map[string]any, error) {
env := []any{}
for index, entry := range c.input.Env {
key, value, _ := strings.Cut(entry, "=")
if c == c.pod.job {
env = append(env, map[string]any{"name": key, "value": value})
continue
}
secretKey := fmt.Sprintf("%s.%d", c.name, index)
secretData[secretKey] = value
env = append(env, map[string]any{"name": key, "valueFrom": map[string]any{"secretKeyRef": map[string]any{"name": secret, "key": secretKey}}})
}
spec := map[string]any{
"name": c.name, "image": c.input.Image, "command": escapeExpansion(c.input.Entrypoint), "args": escapeExpansion(c.input.Cmd),
"workingDir": c.input.WorkingDir, "env": env,
}
if c.pod.options.ForcePull {
spec["imagePullPolicy"] = "Always"
}
if c == c.pod.job {
spec["volumeMounts"] = []any{
map[string]any{"name": "workspace", "mountPath": c.input.WorkingDir},
map[string]any{"name": "act", "mountPath": c.GetActPath()},
}
}
if err := c.parseOptions(); err != nil {
return nil, fmt.Errorf("service %s: %w", c.name, err)
}
if c.probe != nil {
spec["startupProbe"] = c.probe
}
return spec, nil
}
// escapeExpansion keeps kubelet from expanding $(VAR) and $$ in command and args, which docker passes as is.
func escapeExpansion(args []string) []string {
args = slices.Clone(args)
for index := range args {
args[index] = strings.ReplaceAll(args[index], "$", "$$")
}
return args
}
// parseOptions turns docker health options, the only ones a pod supports, into a startup probe, which like docker gives up after the retries.
func (c *kubernetesContainer) parseOptions() error {
args, err := shellquote.Split(c.input.WorkflowOptions)
if err != nil {
return err
}
flags := pflag.NewFlagSet("options", pflag.ContinueOnError)
flags.SetOutput(io.Discard)
command := flags.String("health-cmd", "", "")
interval := flags.Duration("health-interval", 0, "")
timeout := flags.Duration("health-timeout", 0, "")
retries := flags.Int("health-retries", 0, "")
startPeriod := flags.Duration("health-start-period", 0, "")
if err := flags.Parse(args); err != nil {
return fmt.Errorf("options %q: %w, only --health-* options are supported on Kubernetes", c.input.WorkflowOptions, err)
}
if *command == "" {
return nil
}
period := max(1, int(cmp.Or(*interval, 30*time.Second).Seconds()))
c.probe = map[string]any{
"exec": map[string]any{"command": []string{"/bin/sh", "-c", *command}},
"periodSeconds": period,
"timeoutSeconds": max(1, int(cmp.Or(*timeout, 30*time.Second).Seconds())),
"failureThreshold": max(1, cmp.Or(*retries, 3)) + int(startPeriod.Seconds())/period, // docker does not count failures in the start period
}
return nil
}
func (p *KubernetesPod) get(ctx context.Context) (*podStatus, error) {
var pod podStatus
return &pod, p.client.do(ctx, http.MethodGet, p.client.path("pods/"+p.name), nil, &pod)
}
func (p *KubernetesPod) waitStarted(ctx context.Context) error {
var unscheduled string
for delay := 100 * time.Millisecond; ; delay = min(2*delay, time.Second) {
pod, err := p.get(ctx)
if err != nil {
return err
}
if pod.Status.Phase == "Failed" {
return fmt.Errorf("pod %s failed: %s", p.name, cmp.Or(pod.Status.Message, pod.Status.Reason))
}
started := len(pod.Status.ContainerStatuses) == len(pod.Spec.Containers)
for _, status := range append(pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses...) {
if waiting := status.State.Waiting; waiting != nil {
switch waiting.Reason {
case "ErrImagePull", "ImagePullBackOff", "InvalidImageName", "ErrImageNeverPull", "CreateContainerConfigError", "CreateContainerError", "RunContainerError":
return fmt.Errorf("container %s of pod %s: %s: %s", status.Name, p.name, waiting.Reason, waiting.Message)
}
started = false
}
}
if started {
return nil
}
for _, condition := range pod.Status.Conditions {
if condition.Type == "PodScheduled" && condition.Status == "False" && condition.Message != unscheduled {
unscheduled = condition.Message
common.Logger(ctx).Infof("Waiting for pod %s to be scheduled: %s", p.name, unscheduled)
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
}
func (c *kubernetesContainer) Create(_, _ []string) common.Executor {
return func(ctx context.Context) error {
if c != c.pod.job {
return nil
}
return c.pod.create(ctx)
}
}
func (c *kubernetesContainer) Start(_ bool) common.Executor {
return func(ctx context.Context) error {
if c != c.pod.job {
return nil
}
if err := c.pod.waitStarted(ctx); err != nil {
return err
}
var stdout bytes.Buffer
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `printf '%s\n%s' "$(uname -m)" "$PATH"`}, nil, &stdout, io.Discard); err != nil {
return err
}
machine, path, _ := strings.Cut(stdout.String(), "\n")
c.pod.arch, c.pod.path = goArchToActionArch(machine), path
return nil
}
}
func (*kubernetesContainer) Pull(bool) common.Executor {
return func(context.Context) error { return nil }
}
var (
shellName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
bashReadonly = []string{"BASHOPTS", "BASH_VERSINFO", "EUID", "PPID", "SHELLOPTS", "UID"} // bash as sh cannot export these
)
// Exec feeds the command to sh on stdin, which exports the env so it stays out of the request URL and, where sh can export a name, out of process arguments.
func (c *kubernetesContainer) Exec(command []string, env map[string]string, _, workdir string) common.Executor {
return func(ctx context.Context) error {
script, args := "", []string{"env", "--"}
for name, value := range env {
if shellName.MatchString(name) && !slices.Contains(bashReadonly, name) {
script += "command export " + shellquote.Join(name+"="+value) + "\n"
} else {
args = append(args, name+"="+value)
}
}
script += "exec " + shellquote.Join(append(args, command...)...)
if workdir != "" {
script = "cd " + shellquote.Join(workdir) + " || exit\n" + script
}
defer common.FlushWriter(c.input.Stdout)
defer common.FlushWriter(c.input.Stderr)
return c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh"}, strings.NewReader(script), c.input.Stdout, c.input.Stderr)
}
}
func (c *kubernetesContainer) extract(ctx context.Context, destPath string, write func(io.Writer) error) error {
reader, writer := io.Pipe()
defer reader.Close()
go func() { writer.CloseWithError(write(writer)) }()
var stderr bytes.Buffer
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `mkdir -p "$1" && tar -xf - -C "$1"`, "sh", destPath}, reader, io.Discard, &stderr); err != nil {
return fmt.Errorf("extract to %s: %w: %s", destPath, err, strings.TrimSpace(stderr.String()))
}
return nil
}
func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.Executor {
return func(ctx context.Context) error {
return c.extract(ctx, destPath, func(writer io.Writer) error { return writeFilesTar(ctx, writer, 0, 0, files...) })
}
}
func (c *kubernetesContainer) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
return func(ctx context.Context) error {
return c.extract(ctx, "/", func(writer io.Writer) error {
return writeDirTar(ctx, writer, destPath, srcPath, useGitIgnore, skipGitDir, 0, 0)
})
}
}
// GetContainerArchive streams the archive, checking the path first as tar writes an empty archive for a missing one.
func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
reader, writer := io.Pipe()
go func() {
var stderr bytes.Buffer
err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `test -e "$1" -o -L "$1" && exec tar -cf - -C "$2" "$3"`, "sh", srcPath, path.Dir(srcPath), path.Base(srcPath)}, nil, writer, &stderr)
if err != nil {
err = fmt.Errorf("archive %s: %w: %s", srcPath, err, strings.TrimSpace(stderr.String()))
}
writer.CloseWithError(err)
}()
buffered := bufio.NewReader(reader)
if _, err := buffered.Peek(1); err != nil {
reader.Close()
return nil, err
}
return struct {
io.Reader
io.Closer
}{buffered, reader}, nil
}
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
return parseEnvFile(c, srcPath, env)
}
func (c *kubernetesContainer) UpdateFromImageEnv(env *map[string]string) common.Executor {
return func(context.Context) error {
(*env)["PATH"] = cmp.Or((*env)["PATH"], c.pod.path)
return nil
}
}
func (c *kubernetesContainer) Inspect(ctx context.Context) (*Info, error) {
if c.pod.name == "" {
return nil, fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
}
pod, err := c.pod.get(ctx)
if isNotFound(err) {
return nil, fmt.Errorf("pod %s %w", c.pod.name, ErrContainerNotFound)
} else if err != nil {
return nil, err
}
info := &Info{ID: c.pod.name + "/" + c.name, State: "created", Health: HealthNone, Ports: map[string]string{}}
for port := range c.input.ExposedPorts {
info.Ports[port.Port()] = port.Port() // services share the pod's network
}
for _, status := range pod.Status.ContainerStatuses {
if status.Name != c.name {
continue
}
if status.State.Running != nil {
info.State = StateRunning
} else if terminated := status.State.Terminated; terminated != nil {
info.State, info.ExitCode, info.HealthOutput = "exited", terminated.ExitCode, terminated.Message
}
if c.probe != nil {
switch {
case status.Ready:
info.Health = HealthHealthy
case info.State == "exited":
info.Health = HealthUnhealthy
default:
info.Health = HealthStarting
}
}
}
return info, nil
}
func (c *kubernetesContainer) DumpLogs(ctx context.Context) error {
if c.pod.name == "" {
return fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
}
defer common.FlushWriter(c.input.Stdout)
return c.pod.client.do(ctx, http.MethodGet, c.pod.client.path("pods/"+c.pod.name+"/log?container="+c.name), nil, c.input.Stdout)
}
func (c *kubernetesContainer) Remove() common.Executor {
return func(ctx context.Context) error {
if c != c.pod.job {
return nil
}
return errors.Join(c.pod.client.delete(ctx, "pods", c.pod.name), c.pod.client.delete(ctx, "secrets", c.pod.secretName))
}
}
// RemoveOrphanKubernetesResources removes this runner's job pods created before createdBefore and the Secrets named like them, as listing Secrets would reveal all of the namespace's.
func RemoveOrphanKubernetesResources(ctx context.Context, options KubernetesOptions, createdBefore time.Time) error {
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
if err != nil {
return fmt.Errorf("connect to Kubernetes: %w", err)
}
defer client.http.CloseIdleConnections()
var pods struct {
Items []struct {
Metadata struct {
Name string
CreationTimestamp time.Time
}
}
}
if err := client.do(ctx, http.MethodGet, client.path("pods?labelSelector="+url.QueryEscape(runnerUUIDLabel+"="+options.RunnerUUID)), nil, &pods); err != nil {
return fmt.Errorf("list pods: %w", err)
}
var errs []error
for _, item := range pods.Items {
if !item.Metadata.CreationTimestamp.Before(createdBefore) {
continue
}
if err := client.delete(ctx, "secrets", item.Metadata.Name); err != nil {
errs = append(errs, err) // leave the pod for the next cleanup, it is the only way back to its Secret
continue
}
errs = append(errs, client.delete(ctx, "pods", item.Metadata.Name))
}
return errors.Join(errs...)
}
func (c *kubernetesContainer) Close() common.Executor {
return func(context.Context) error {
c.pod.client.http.CloseIdleConnections()
return nil
}
}
func (c *kubernetesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
oldStdout, oldStderr := c.input.Stdout, c.input.Stderr
c.input.Stdout, c.input.Stderr = stdout, stderr
return oldStdout, oldStderr
}
func (c *kubernetesContainer) GetRunnerContext(_ context.Context) map[string]any {
return map[string]any{"os": "Linux", "arch": c.pod.arch, "temp": "/tmp", "tool_cache": DefaultToolCache}
}
+400
View File
@@ -0,0 +1,400 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"bytes"
"cmp"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/coder/websocket"
"go.yaml.in/yaml/v4"
)
const (
serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount"
execProtocol = "v5.channel.k8s.io"
)
type kubeClient struct {
server, namespace, token string
tokenFile string // re-read on every request, service account tokens rotate
inCluster bool
http *http.Client
}
type apiError struct {
code int
message string
}
func (e *apiError) Error() string { return e.message }
func isNotFound(err error) bool {
var apiErr *apiError
return errors.As(err, &apiErr) && apiErr.code == http.StatusNotFound
}
func newKubeClient(kubeconfig, namespace string) (*kubeClient, error) {
client := &kubeClient{namespace: namespace}
transport := &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
if host := os.Getenv("KUBERNETES_SERVICE_HOST"); host != "" && kubeconfig == "" {
if err := client.loadInCluster(host, transport.TLSClientConfig); err != nil {
return nil, err
}
} else if err := client.loadKubeconfig(kubeconfig, transport); err != nil {
return nil, err
}
client.http = &http.Client{Transport: transport}
return client, nil
}
func (c *kubeClient) loadInCluster(host string, tlsConfig *tls.Config) error {
c.server = "https://" + net.JoinHostPort(host, os.Getenv("KUBERNETES_SERVICE_PORT"))
c.tokenFile = serviceAccountDir + "/token"
ca, err := os.ReadFile(serviceAccountDir + "/ca.crt")
if err != nil {
return err
}
tlsConfig.RootCAs = x509.NewCertPool()
tlsConfig.RootCAs.AppendCertsFromPEM(ca)
if c.namespace == "" {
namespace, err := os.ReadFile(serviceAccountDir + "/namespace")
if err != nil {
return err
}
c.namespace = strings.TrimSpace(string(namespace))
}
c.inCluster = true
return nil
}
func (c *kubeClient) loadKubeconfig(path string, transport *http.Transport) error {
if paths := filepath.SplitList(os.Getenv("KUBECONFIG")); path == "" && len(paths) > 0 {
path = paths[0]
}
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
return err
}
path = filepath.Join(home, ".kube", "config")
}
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("no in-cluster service account and no kubeconfig: %w", err)
}
var config struct {
CurrentContext string `yaml:"current-context"`
Contexts []struct {
Name string
Context struct{ Cluster, User, Namespace string }
}
Clusters []struct {
Name string
Cluster struct {
Server string
TLSServerName string `yaml:"tls-server-name"`
InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify"`
ProxyURL string `yaml:"proxy-url"`
CertificateAuthority string `yaml:"certificate-authority"`
CertificateAuthorityData string `yaml:"certificate-authority-data"`
}
}
Users []struct {
Name string
User struct {
Token string
TokenFile string `yaml:"tokenFile"`
ClientCertificate string `yaml:"client-certificate"`
ClientCertificateData string `yaml:"client-certificate-data"`
ClientKey string `yaml:"client-key"`
ClientKeyData string `yaml:"client-key-data"`
Exec any
AuthProvider any `yaml:"auth-provider"`
}
}
}
if err := yaml.Unmarshal(content, &config); err != nil {
return fmt.Errorf("parse kubeconfig %s: %w", path, err)
}
var current struct{ Cluster, User, Namespace string }
for _, entry := range config.Contexts {
if entry.Name == config.CurrentContext {
current = entry.Context
}
}
if current.Cluster == "" {
return fmt.Errorf("kubeconfig %s has no current context", path)
}
c.namespace = cmp.Or(c.namespace, current.Namespace, "default")
resolve := func(file string) string {
if file == "" || filepath.IsAbs(file) {
return file
}
return filepath.Join(filepath.Dir(path), file)
}
read := func(data, file string) ([]byte, error) {
if data != "" {
return base64.StdEncoding.DecodeString(data)
}
if file == "" {
return nil, nil
}
return os.ReadFile(resolve(file))
}
tlsConfig := transport.TLSClientConfig
for _, cluster := range config.Clusters {
if cluster.Name != current.Cluster {
continue
}
c.server = strings.TrimSuffix(cluster.Cluster.Server, "/")
tlsConfig.ServerName, tlsConfig.InsecureSkipVerify = cluster.Cluster.TLSServerName, cluster.Cluster.InsecureSkipTLSVerify
if cluster.Cluster.ProxyURL != "" {
proxy, err := url.Parse(cluster.Cluster.ProxyURL)
if err != nil {
return fmt.Errorf("kubeconfig %s: %w", path, err)
}
transport.Proxy = http.ProxyURL(proxy)
}
ca, err := read(cluster.Cluster.CertificateAuthorityData, cluster.Cluster.CertificateAuthority)
if err != nil {
return err
}
if ca != nil {
tlsConfig.RootCAs = x509.NewCertPool()
tlsConfig.RootCAs.AppendCertsFromPEM(ca)
}
}
for _, user := range config.Users {
if user.Name != current.User {
continue
}
if user.User.Exec != nil || user.User.AuthProvider != nil {
return fmt.Errorf("kubeconfig %s: user %q authenticates through a credential plugin, which the runner does not support, use a token or client certificate", path, user.Name)
}
c.token, c.tokenFile = user.User.Token, resolve(user.User.TokenFile)
cert, err := read(user.User.ClientCertificateData, user.User.ClientCertificate)
if err != nil {
return err
}
key, err := read(user.User.ClientKeyData, user.User.ClientKey)
if err != nil {
return err
}
if cert != nil {
pair, err := tls.X509KeyPair(cert, key)
if err != nil {
return err
}
tlsConfig.Certificates = []tls.Certificate{pair}
}
}
if c.server == "" {
return fmt.Errorf("kubeconfig %s names no server for context %q", path, config.CurrentContext)
}
return nil
}
func (c *kubeClient) authorize(header http.Header) error {
token := c.token
if c.tokenFile != "" {
content, err := os.ReadFile(c.tokenFile)
if err != nil {
return err
}
token = strings.TrimSpace(string(content))
}
if token != "" {
header.Set("Authorization", "Bearer "+token)
}
return nil
}
func (c *kubeClient) path(resource string) string {
return "/api/v1/namespaces/" + c.namespace + "/" + resource
}
func (c *kubeClient) do(ctx context.Context, method, path string, in, out any) error {
var body io.Reader
if in != nil {
content, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(content)
}
req, err := http.NewRequestWithContext(ctx, method, c.server+path, body)
if err != nil {
return err
}
if err := c.authorize(req.Header); err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusMultipleChoices {
return statusError(resp)
}
if out == nil {
out = io.Discard // draining lets the connection be reused
}
if writer, ok := out.(io.Writer); ok {
_, err = io.Copy(writer, resp.Body)
return err
}
return json.NewDecoder(resp.Body).Decode(out)
}
func (c *kubeClient) delete(ctx context.Context, resource, name string) error {
if name == "" {
return nil
}
if err := c.do(ctx, http.MethodDelete, c.path(resource+"/"+name), nil, nil); err != nil && !isNotFound(err) {
return fmt.Errorf("delete %s %s: %w", resource, name, err)
}
return nil
}
func statusError(resp *http.Response) error {
content, _ := io.ReadAll(resp.Body)
var status struct{ Message string }
if json.Unmarshal(content, &status) != nil || status.Message == "" {
status.Message = strings.TrimSpace(string(content))
}
return &apiError{code: resp.StatusCode, message: status.Message}
}
func (c *kubeClient) exec(ctx context.Context, pod, containerName string, command []string, stdin io.Reader, stdout, stderr io.Writer) error {
query := url.Values{"container": {containerName}, "command": command, "stdout": {"true"}, "stderr": {"true"}}
if stdin != nil {
query.Set("stdin", "true")
}
header := http.Header{}
if err := c.authorize(header); err != nil {
return err
}
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
conn, resp, err := websocket.Dial(ctx, c.server+c.path("pods/"+pod+"/exec?")+query.Encode(), &websocket.DialOptions{
HTTPClient: c.http,
HTTPHeader: header,
Subprotocols: []string{execProtocol},
})
if err != nil {
if resp != nil && resp.StatusCode >= http.StatusMultipleChoices {
defer resp.Body.Close()
err = statusError(resp)
}
return fmt.Errorf("exec in pod %s: %w", pod, err)
}
defer func() { _ = conn.CloseNow() }()
if conn.Subprotocol() != execProtocol {
return fmt.Errorf("the Kubernetes API server does not speak %s, which needs Kubernetes 1.30 or later", execProtocol)
}
conn.SetReadLimit(-1)
if stdin != nil {
go func() {
if err := sendStdin(ctx, conn, stdin); err != nil {
cancel(err)
}
}()
}
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if conn.Ping(ctx) != nil {
return
}
}
}
}()
for {
_, frame, err := conn.Read(ctx)
if err != nil {
return cmp.Or(context.Cause(ctx), fmt.Errorf("exec in pod %s ended without an exit status: %w", pod, err))
}
if len(frame) < 2 {
continue
}
switch frame[0] {
case 1:
_, err = stdout.Write(frame[1:])
case 2:
_, err = stderr.Write(frame[1:])
case 3:
return cmp.Or(context.Cause(ctx), execStatus(frame[1:]))
}
if err != nil {
return err
}
}
}
func sendStdin(ctx context.Context, conn *websocket.Conn, stdin io.Reader) error {
frame := make([]byte, 1+32*1024) // frame[0] is the stdin channel, 0
for {
n, err := stdin.Read(frame[1:])
if n > 0 {
if err := conn.Write(ctx, websocket.MessageBinary, frame[:1+n]); err != nil {
return err
}
}
if errors.Is(err, io.EOF) {
return conn.Write(ctx, websocket.MessageBinary, []byte{255, 0}) // v5 close signal for stdin
}
if err != nil {
return err
}
}
}
func execStatus(content []byte) error {
var status struct {
Status, Message string
Details struct {
Causes []struct{ Reason, Message string }
}
}
if err := json.Unmarshal(content, &status); err != nil {
return fmt.Errorf("decode exec status: %w", err)
}
if status.Status == "Success" {
return nil
}
for _, cause := range status.Details.Causes {
if code, err := strconv.Atoi(cause.Message); cause.Reason == "ExitCode" && err == nil {
return ExitCodeError(code)
}
}
return fmt.Errorf("exec failed: %s", status.Message)
}
+290
View File
@@ -0,0 +1,290 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"archive/tar"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/coder/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type podManifest struct {
Metadata struct{ Labels map[string]string }
Spec struct {
AutomountServiceAccountToken bool
EnableServiceLinks bool
HostAliases []struct{ Hostnames []string }
Volumes []map[string]any
Containers []struct {
Name, ImagePullPolicy string
Resources map[string]any
StartupProbe *struct{ PeriodSeconds, TimeoutSeconds, FailureThreshold int }
}
}
}
type fakeCluster struct {
mu sync.Mutex
requests map[string][]string
}
func (f *fakeCluster) record(request, content string) {
f.mu.Lock()
defer f.mu.Unlock()
f.requests[request] = append(f.requests[request], content)
}
func fakeKubernetes(t *testing.T, podStatus string) (string, *fakeCluster) {
cluster := &fakeCluster{requests: map[string][]string{}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
cluster.record(r.Method+" "+r.URL.Path, string(body))
pods, secrets := "/api/v1/namespaces/ci/pods", "/api/v1/namespaces/ci/secrets"
switch {
case r.Method == http.MethodGet && r.URL.Path == pods:
cluster.record("list", r.URL.Query().Get("labelSelector"))
_, _ = io.WriteString(w, `{"items":[{"metadata":{"name":"old","creationTimestamp":"2020-01-01T00:00:00Z"}},{"metadata":{"name":"new","creationTimestamp":"2999-01-01T00:00:00Z"}}]}`)
case strings.HasSuffix(r.URL.Path, "/exec"):
cluster.record("exec", r.URL.RawQuery)
serveLocalExec(t, w, r)
case strings.HasSuffix(r.URL.Path, "/log"):
_, _ = io.WriteString(w, "log of "+r.URL.Query().Get("container"))
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, pods+"/"):
_, _ = io.WriteString(w, podStatus)
case r.Method == http.MethodPost && (r.URL.Path == pods || r.URL.Path == secrets), r.Method == http.MethodDelete:
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
kubeconfig := filepath.Join(t.TempDir(), "kubeconfig")
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(kubeconfig), "token"), []byte("test-token\n"), 0o600))
require.NoError(t, os.WriteFile(kubeconfig, fmt.Appendf(nil, `
current-context: test
contexts: [{name: test, context: {cluster: test, user: test, namespace: ci}}]
clusters: [{name: test, cluster: {server: "http://kubernetes.invalid", proxy-url: %q}}]
users: [{name: test, user: {tokenFile: token}}]
`, server.URL), 0o600))
return kubeconfig, cluster
}
func serveLocalExec(t *testing.T, w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{execProtocol}})
if err != nil {
t.Error(err)
return
}
defer func() { _ = conn.CloseNow() }()
conn.SetReadLimit(-1)
command := r.URL.Query()["command"]
var stdin bytes.Buffer
for r.URL.Query().Get("stdin") == "true" {
_, frame, err := conn.Read(r.Context())
if err != nil {
t.Error(err)
return
}
if frame[0] == 255 {
break
}
stdin.Write(frame[1:])
}
cmd := exec.Command(command[0], command[1:]...)
cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1")
var stdout, stderr bytes.Buffer
cmd.Stdin, cmd.Stdout, cmd.Stderr = &stdin, &stdout, &stderr
status := `{"status":"Success"}`
var exitErr *exec.ExitError
if err := cmd.Run(); errors.As(err, &exitErr) {
status = fmt.Sprintf(`{"status":"Failure","reason":"NonZeroExitCode","details":{"causes":[{"reason":"ExitCode","message":"%d"}]}}`, exitErr.ExitCode())
} else {
assert.NoError(t, err)
}
for _, frame := range [][]byte{append([]byte{1}, stdout.Bytes()...), append([]byte{2}, stderr.Bytes()...), append([]byte{3}, status...)} {
_ = conn.Write(r.Context(), websocket.MessageBinary, frame)
}
}
func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
ctx := t.Context()
kubeconfig, cluster := fakeKubernetes(t, `{"spec":{"containers":[{},{}]},"status":{"phase":"Running","containerStatuses":[
{"name":"job","ready":true,"state":{"running":{}}},
{"name":"my-db","ready":true,"state":{"running":{}}}]}}`)
workdir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
options := KubernetesOptions{Kubeconfig: kubeconfig, RunnerUUID: "runner-uuid", PodTemplates: []map[string]any{
{"spec": map[string]any{
"automountServiceAccountToken": true,
"containers": []any{
map[string]any{"name": "job", "resources": map[string]any{"limits": map[string]any{"cpu": "2"}}},
map[string]any{
"name": "$services", "imagePullPolicy": "Always", "resources": map[string]any{"limits": map[string]any{"memory": "256Mi"}},
"env": []any{map[string]any{"name": "POSTGRES_PASSWORD", "value": "template"}},
},
},
}},
{"spec": map[string]any{
"volumes": []any{map[string]any{"name": "workspace", "emptyDir": map[string]any{"sizeLimit": "1Gi"}}},
"containers": []any{map[string]any{"name": "job", "resources": map[string]any{"limits": map[string]any{"memory": "1Gi"}}}},
}},
}}
pod, err := NewKubernetesPod(options)
require.NoError(t, err)
var jobLog, serviceLog bytes.Buffer
job := pod.JobContainer(&NewContainerInput{Name: "Job-Name", Image: "node", WorkingDir: workdir, Stdout: &jobLog, Stderr: &jobLog})
service := pod.ServiceContainer("My_DB", &NewContainerInput{
Image: "postgres",
Cmd: []string{"postgres", "-D", "$(PGDATA)"},
Env: []string{"POSTGRES_PASSWORD=service-secret"},
WorkflowOptions: "--health-cmd pg_isready --health-interval 2s --health-start-period 4s",
Stdout: &serviceLog,
})
require.NoError(t, job.Create(nil, nil)(ctx))
var manifest podManifest
require.NoError(t, json.Unmarshal([]byte(cluster.requests["POST /api/v1/namespaces/ci/pods"][0]), &manifest))
assert.Equal(t, "gitea-runner", manifest.Metadata.Labels["app.kubernetes.io/managed-by"])
assert.Equal(t, "runner-uuid", manifest.Metadata.Labels[runnerUUIDLabel])
assert.True(t, manifest.Spec.AutomountServiceAccountToken)
assert.False(t, manifest.Spec.EnableServiceLinks)
assert.Equal(t, []string{"my-db"}, manifest.Spec.HostAliases[0].Hostnames)
require.Len(t, manifest.Spec.Containers, 2)
assert.Equal(t, map[string]any{"limits": map[string]any{"cpu": "2", "memory": "1Gi"}}, manifest.Spec.Containers[0].Resources)
assert.Empty(t, manifest.Spec.Containers[0].ImagePullPolicy)
assert.Equal(t, "my-db", manifest.Spec.Containers[1].Name)
assert.Equal(t, "Always", manifest.Spec.Containers[1].ImagePullPolicy)
assert.Equal(t, map[string]any{"limits": map[string]any{"memory": "256Mi"}}, manifest.Spec.Containers[1].Resources)
assert.Equal(t, []map[string]any{
{"name": "workspace", "emptyDir": map[string]any{"sizeLimit": "1Gi"}},
{"name": "act", "emptyDir": map[string]any{}},
}, manifest.Spec.Volumes)
assert.Equal(t, struct{ PeriodSeconds, TimeoutSeconds, FailureThreshold int }{2, 30, 5}, *manifest.Spec.Containers[1].StartupProbe)
assert.NotContains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], "service-secret")
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/secrets"][0], "service-secret")
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/secrets"][0], `"com.gitea.runner.uuid":"runner-uuid"`)
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], `{"name":"POSTGRES_PASSWORD","valueFrom":{"secretKeyRef":{"key":"my-db.0","name":"`+pod.secretName+`"}}}`)
assert.Equal(t, []any{"new"}, mergeManifest([]any{"old"}, []any{"new"}, ""))
mount := func(path string) any { return map[string]any{"name": "config", "mountPath": path} }
assert.Equal(t, []any{mount("/a"), mount("/b")}, mergeManifest([]any{mount("/a")}, []any{mount("/b")}, "volumeMounts"))
tmpfs := map[string]any{"name": "workspace", "emptyDir": map[string]any{"medium": "Memory"}}
ephemeral := map[string]any{"name": "workspace", "ephemeral": map[string]any{}}
assert.Equal(t, []any{ephemeral}, mergeManifest([]any{tmpfs}, []any{ephemeral}, "volumes"))
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], `"args":["postgres","-D","$$(PGDATA)"]`)
require.NoError(t, os.WriteFile(filepath.Join(workdir, "env"), []byte("#!/bin/sh\necho \"$@\" > \"$0-args\"\nexec /usr/bin/env \"$@\"\n"), 0o755))
t.Setenv("PATH", workdir+":"+os.Getenv("PATH"))
require.NoError(t, job.Start(false)(ctx))
stepEnv := map[string]string{"INPUT_WHO-TO-GREET": "it's\nme", "SECRET": "hunter2", "UID": "5"}
require.NoError(t, job.Exec([]string{"printenv", "SECRET"}, stepEnv, "", "")(ctx))
envArgs, err := os.ReadFile(filepath.Join(workdir, "env-args"))
require.NoError(t, err)
assert.NotContains(t, string(envArgs), "hunter2")
require.NoError(t, job.Exec([]string{"printenv", "INPUT_WHO-TO-GREET"}, stepEnv, "", "")(ctx))
require.NoError(t, job.Exec([]string{"printenv", "UID"}, stepEnv, "", "")(ctx))
require.NoError(t, job.Exec([]string{"pwd", "-P"}, nil, "", workdir)(ctx))
assert.Equal(t, "hunter2\nit's\nme\n5\n"+workdir+"\n", jobLog.String())
assert.Equal(t, ExitCodeError(3), job.Exec([]string{"sh", "-c", "echo failing >&2; exit 3"}, nil, "", "")(ctx))
assert.Contains(t, jobLog.String(), "failing\n")
for _, query := range cluster.requests["exec"] {
assert.NotContains(t, query, "hunter2")
}
require.NoError(t, job.Copy(workdir+"/act/", &FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: `{"ref":"main"}`})(ctx))
archive, err := job.GetContainerArchive(ctx, workdir+"/act/workflow/event.json")
require.NoError(t, err)
reader := tar.NewReader(archive)
header, err := reader.Next()
require.NoError(t, err)
content, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "event.json", header.Name)
assert.JSONEq(t, `{"ref":"main"}`, string(content))
require.NoError(t, archive.Close())
_, err = job.GetContainerArchive(ctx, workdir+"/missing")
require.Error(t, err)
pathEnv := map[string]string{}
require.NoError(t, job.UpdateFromImageEnv(&pathEnv)(ctx))
assert.Equal(t, os.Getenv("PATH"), pathEnv["PATH"])
info, err := service.Inspect(ctx)
require.NoError(t, err)
assert.Equal(t, HealthHealthy, info.Health)
require.NoError(t, service.DumpLogs(ctx))
assert.Equal(t, "log of my-db", serviceLog.String())
require.NoError(t, job.Remove()(ctx))
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/pods/"+pod.name], 1)
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/"+pod.secretName], 1)
require.NoError(t, RemoveOrphanKubernetesResources(ctx, options, time.Now()))
assert.Equal(t, []string{runnerUUIDLabel + "=runner-uuid"}, cluster.requests["list"])
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/pods/old"], 1)
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/old"], 1)
assert.Empty(t, cluster.requests["DELETE /api/v1/namespaces/ci/pods/new"])
assert.Empty(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/new"])
}
func TestKubernetesPodFailsBeforeSteps(t *testing.T) {
kubeconfig, _ := fakeKubernetes(t, `{"spec":{"containers":[{}]},"status":{"containerStatuses":[
{"name":"job","state":{"waiting":{"reason":"ErrImagePull","message":"no such image"}}}]}}`)
template := map[string]any{"spec": map[string]any{"containers": []any{map[string]any{"name": "sidecar"}}}}
for _, test := range []struct {
services []string
options, err string
}{
{[]string{"db"}, "--hostname db", "only --health-* options are supported"},
{[]string{"sidecar"}, "", "named like another container"},
{[]string{"my_db", "My-DB"}, "", `service My-DB maps to container "my-db"`},
} {
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig, Namespace: "ci", PodTemplates: []map[string]any{template}})
require.NoError(t, err)
job := pod.JobContainer(&NewContainerInput{Image: "node"})
for _, service := range test.services {
pod.ServiceContainer(service, &NewContainerInput{Image: "postgres", WorkflowOptions: test.options})
}
require.ErrorContains(t, job.Create(nil, nil)(t.Context()), test.err)
}
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig, Namespace: "ci"})
require.NoError(t, err)
job := pod.JobContainer(&NewContainerInput{Image: "missing"})
require.NoError(t, job.Create(nil, nil)(t.Context()))
require.ErrorContains(t, job.Start(false)(t.Context()), "ErrImagePull: no such image")
plugin := filepath.Join(t.TempDir(), "kubeconfig")
require.NoError(t, os.WriteFile(plugin, []byte(`
current-context: eks
contexts: [{name: eks, context: {cluster: eks, user: eks}}]
clusters: [{name: eks, cluster: {server: "https://eks.example"}}]
users: [{name: eks, user: {exec: {command: aws}}}]
`), 0o600))
_, err = NewKubernetesPod(KubernetesOptions{Kubeconfig: plugin})
require.ErrorContains(t, err, "credential plugin")
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"archive/tar"
"context"
"io"
"path/filepath"
"strings"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector"
"github.com/go-git/go-billy/v5/helper/polyfill"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
)
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
tw := tar.NewWriter(w)
for _, file := range files {
common.Logger(ctx).Debugf("Writing entry to tarball %s len:%d", file.Name, len(file.Body))
if err := tw.WriteHeader(&tar.Header{
Name: file.Name,
Mode: file.Mode,
Size: int64(len(file.Body)),
Uid: uid,
Gid: gid,
}); err != nil {
return err
}
if _, err := tw.Write([]byte(file.Body)); err != nil {
return err
}
}
return tw.Close()
}
// writeDirTar prefixes entries with dstPath for extraction at root.
func writeDirTar(ctx context.Context, w io.Writer, dstPath, srcPath string, useGitIgnore, skipGitDir bool, uid, gid int) error {
logger := common.Logger(ctx)
tw := tar.NewWriter(w)
srcPrefix := strings.TrimSuffix(filepath.Dir(srcPath), string(filepath.Separator)) + string(filepath.Separator)
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
var ignorer gitignore.Matcher
if useGitIgnore {
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
if err != nil {
logger.Debugf("Error loading .gitignore: %v", err)
}
ignorer = gitignore.NewMatcher(ps)
}
if err := filepath.Walk(srcPath, (&filecollector.FileCollector{
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
SkipGitDir: skipGitDir,
Handler: &filecollector.TarCollector{TarWriter: tw, UID: uid, GID: gid, DstDir: dstPath[1:]},
}).CollectFiles(ctx, []string{})); err != nil {
return err
}
return tw.Close()
}
+26 -2
View File
@@ -12,6 +12,7 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"sync"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git" "gitea.com/gitea/runner/act/common/git"
@@ -25,6 +26,26 @@ var (
findGithubRepo = git.FindGithubRepo findGithubRepo = git.FindGithubRepo
) )
type pinnedKey struct{}
// WithPinnedCheckout keeps a run's sha and ref fixed when HEAD moves.
func WithPinnedCheckout(ctx context.Context) context.Context {
if ctx.Value(pinnedKey{}) != nil {
return ctx
}
return context.WithValue(ctx, pinnedKey{}, &sync.Map{})
}
func pinned(ctx context.Context, key string, lookup func() (string, error)) (string, error) {
cache, ok := ctx.Value(pinnedKey{}).(*sync.Map)
if !ok {
return lookup()
}
cached, _ := cache.LoadOrStore(key, sync.OnceValues(lookup))
lookupOnce, _ := cached.(func() (string, error))
return lookupOnce()
}
// SetRef resolves the ref of the context from its event payload, falling back // SetRef resolves the ref of the context from its event payload, falling back
// to the ref checked out in repoPath. // to the ref checked out in repoPath.
func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) { func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
@@ -51,7 +72,7 @@ func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
} }
if ghc.Ref == "" { if ghc.Ref == "" {
ref, err := findGitRef(ctx, repoPath) ref, err := pinned(ctx, "ref:"+repoPath, func() (string, error) { return findGitRef(ctx, repoPath) })
if err != nil { if err != nil {
logger.Warningf("unable to get git ref: %v", err) logger.Warningf("unable to get git ref: %v", err)
} else { } else {
@@ -95,7 +116,10 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
} }
if ghc.Sha == "" { if ghc.Sha == "" {
_, sha, err := findGitRevision(ctx, repoPath) sha, err := pinned(ctx, "sha:"+repoPath, func() (string, error) {
_, revision, err := findGitRevision(ctx, repoPath)
return revision, err
})
if err != nil { if err != nil {
logger.Warningf("unable to get git revision: %v", err) logger.Warningf("unable to get git revision: %v", err)
} else { } else {
+13
View File
@@ -214,4 +214,17 @@ func TestSetSha(t *testing.T) {
assert.Equal(t, table.sha, ghc.Sha) assert.Equal(t, table.sha, ghc.Sha)
}) })
} }
t.Run("pinned checkout keeps the first sha and ref", func(t *testing.T) {
ctx := WithPinnedCheckout(context.Background())
for _, head := range []string{"first", "moved"} {
findGitRevision = func(context.Context, string) (string, string, error) { return "", head, nil }
findGitRef = func(context.Context, string) (string, error) { return "refs/heads/" + head, nil }
ghc := &model.GithubContext{Event: map[string]any{}}
SetSha(ctx, ghc, "/some/dir")
SetRef(ctx, ghc, "/some/dir")
assert.Equal(t, "first", ghc.Sha)
assert.Equal(t, "refs/heads/first", ghc.Ref)
}
})
} }
+38 -37
View File
@@ -6,6 +6,7 @@ package runner
import ( import (
"context" "context"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"embed" "embed"
"encoding/hex" "encoding/hex"
@@ -13,6 +14,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"maps"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -286,11 +288,30 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin
return strings.ToLower(image) return strings.ToLower(image)
} }
// One image per step and job: its stages share a build, and concurrent jobs never run each other's.
func (rc *RunContext) dockerActionImage(ctx context.Context, step actionStep, actionName string, localAction bool) string {
job := rc.topLevelRunContext()
if job.dockerActionImages == nil {
job.dockerActionImages = map[*model.Step]string{}
}
image, ok := job.dockerActionImages[step.getStepModel()]
if !ok {
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction) + "-" + strings.ToLower(rand.Text())
if !common.Dryrun(ctx) { // a dry run builds nothing to remove
job.dockerActionImages[step.getStepModel()] = image
}
}
return image
}
// TODO: break out parts of function to reduce complexicity // TODO: break out parts of function to reduce complexicity
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error { func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
rc := step.getRunContext() rc := step.getRunContext()
action := step.getActionModel() action := step.getActionModel()
if rc.kubernetes {
return errKubernetesDocker
}
var prepImage common.Executor var prepImage common.Executor
var image string var image string
@@ -300,7 +321,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
// Apply forcePull only for prebuild docker images // Apply forcePull only for prebuild docker images
forcePull = rc.Config.ForcePull forcePull = rc.Config.ForcePull
} else { } else {
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction) image = rc.dockerActionImage(ctx, step, actionName, localAction)
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image)) contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any") anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
@@ -353,12 +374,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture) logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
} }
} }
eval := rc.NewActionInputsExpressionEvaluator(ctx, step) cmd, err := shellquote.Split(step.getStepModel().With["args"])
args, err := eval.Interpolate(ctx, step.getStepModel().With["args"])
if err != nil {
return fmt.Errorf("unable to interpolate with.args: %w", err)
}
cmd, err := shellquote.Split(args)
if err != nil { if err != nil {
return err return err
} }
@@ -375,14 +391,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
} }
} }
} }
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage) entrypoint, err := dockerEntrypoint(step, stage)
if err != nil {
return err
}
stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
if err != nil { if err != nil {
return err return err
} }
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
prepImage, prepImage,
stepContainer.Pull(forcePull), stepContainer.Pull(forcePull),
@@ -394,7 +407,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
// dockerEntrypoint returns the entrypoint the action's image runs with for the given // dockerEntrypoint returns the entrypoint the action's image runs with for the given
// stage. Only the main stage honours the `entrypoint` input. // stage. Only the main stage honours the `entrypoint` input.
func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEvaluator, stage stepStage) ([]string, error) { func dockerEntrypoint(step actionStep, stage stepStage) ([]string, error) {
runs := step.getActionModel().Runs runs := step.getActionModel().Runs
var entrypoint string var entrypoint string
@@ -406,11 +419,7 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
default: default:
entrypoint = runs.Entrypoint entrypoint = runs.Entrypoint
if entrypoint == "" { if entrypoint == "" {
withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"]) if fields := strings.Fields(step.getStepModel().With["entrypoint"]); len(fields) > 0 {
if err != nil {
return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
}
if fields := strings.Fields(withEntrypoint); len(fields) > 0 {
return fields, nil return fields, nil
} }
} }
@@ -425,7 +434,6 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
// evalDockerEnv returns an evaluator bound to the environment it installed. // evalDockerEnv returns an evaluator bound to the environment it installed.
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) { func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
rc := step.getRunContext() rc := step.getRunContext()
stepModel := step.getStepModel()
var err error var err error
inputs := make(map[string]string) inputs := make(map[string]string)
@@ -436,40 +444,33 @@ func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expre
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err) return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
} }
} }
for k, v := range stepModel.With { mergeIntoMap(step, step.getEnv(), inputs, step.getStepModel().With)
if inputs[k], err = eval.Interpolate(ctx, v); err != nil {
return nil, fmt.Errorf("unable to interpolate with.%s: %w", k, err)
}
}
mergeIntoMap(step, step.getEnv(), inputs)
env := make(map[string]string, len(action.Runs.Env)+len(*step.getEnv()))
mergeIntoMap(step, &env, action.Runs.Env, *step.getEnv())
*step.getEnv() = env
ee := rc.NewActionInputsExpressionEvaluator(ctx, step) ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
for k, v := range *step.getEnv() { runsEnv := make(map[string]string, len(action.Runs.Env))
if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil { for k, v := range action.Runs.Env {
if runsEnv[k], err = ee.Interpolate(ctx, v); err != nil {
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err) return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
} }
} }
mergeIntoMap(step, step.getEnv(), runsEnv, maps.Clone(*step.getEnv()))
return ee, nil return ee, nil
} }
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) (container.Container, error) { func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container {
rc := step.getRunContext() rc := step.getRunContext()
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
envList := make([]string, 0) envList := make([]string, 0)
for k, v := range *step.getEnv() { for k, v := range *step.getEnv() {
if rc.IsHostEnv() && strings.EqualFold(k, rc.JobContainer.GetPathVariableName()) && v == rc.JobContainer.DefaultPathVariable() { // the runner's own PATH stays out, as on GitHub
continue
}
envList = append(envList, fmt.Sprintf("%s=%s", k, v)) envList = append(envList, fmt.Sprintf("%s=%s", k, v))
} }
envList = append(envList, rc.runnerEnv(ctx)...) envList = append(envList, rc.runnerEnv(ctx)...)
binds, mounts, err := rc.GetBindsAndMounts() binds, mounts := rc.GetBindsAndMounts(ctx)
if err != nil {
return nil, err
}
networkMode := "container:" + rc.jobContainerName() networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv() { if rc.IsHostEnv() {
networkMode = "default" networkMode = "default"
@@ -493,7 +494,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(), ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
}), nil })
} }
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) { func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
+4 -1
View File
@@ -29,7 +29,7 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
} }
} }
ee := parent.NewActionInputsExpressionEvaluator(ctx, step) ee := parent.NewStepExpressionEvaluator(ctx, step)
for inputID, input := range step.getActionModel().Inputs { for inputID, input := range step.getActionModel().Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
@@ -104,6 +104,9 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
Parent: parent, Parent: parent,
EventJSON: parent.EventJSON, EventJSON: parent.EventJSON,
platformImage: parent.platformImage, platformImage: parent.platformImage,
kubernetes: parent.kubernetes,
jobIndex: parent.jobIndex,
jobTotal: parent.jobTotal,
} }
compositerc.setCompositeActionEnv(env) compositerc.setCompositeActionEnv(env)
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx) compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
+54 -3
View File
@@ -12,18 +12,21 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
) )
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{MaxParallelString: "3"} strategy := &model.Strategy{MaxParallelString: "3", RawMatrix: yaml.Node{Kind: yaml.MappingNode}}
parent := &RunContext{ parent := &RunContext{
Config: &Config{}, Config: &Config{},
Matrix: map[string]any{"os": "linux"}, Matrix: map[string]any{"os": "linux"},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
JobContainer: &jobContainerMock{}, JobContainer: &jobContainerMock{},
platformImage: "-self-hosted", platformImage: "-self-hosted",
jobIndex: 1,
jobTotal: 4,
} }
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{ composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
Step: &model.Step{With: map[string]string{"SHARED": "outer"}}, Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
@@ -36,9 +39,9 @@ func TestCompositeActionParity(t *testing.T) {
assert.Same(t, strategy, composite.Run.Job().Strategy) assert.Same(t, strategy, composite.Run.Job().Strategy)
assert.True(t, composite.IsHostEnv()) assert.True(t, composite.IsHostEnv())
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx, interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}") "${{ matrix.os }}|${{ strategy.max-parallel }}|${{ strategy.job-index }}|${{ inputs.shared }}")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "linux|3|outer", interpolated) assert.Equal(t, "linux|3|1|outer", interpolated)
assert.NotContains(t, composite.Env, "INPUT_SHARED") assert.NotContains(t, composite.Env, "INPUT_SHARED")
nestedEnv := composite.GetEnv() nestedEnv := composite.GetEnv()
@@ -117,3 +120,51 @@ func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) {
assert.Equal(t, []string{"secret"}, parentMasks) assert.Equal(t, []string{"secret"}, parentMasks)
} }
func TestCompositeStepIfReadsJobStatusExceptInItsOwnMainSteps(t *testing.T) {
failStep := func(rc *RunContext) {
rc.StepResults["failed"] = &model.StepResult{Conclusion: model.StepStatusFailure}
}
tests := []struct {
name string
setup func(job, composite *RunContext)
nested bool
stage stepStage
want map[string]bool
}{
{"post-if reads the failed job", func(job, _ *RunContext) { failStep(job) }, false, stepStagePost, map[string]bool{"success()": false, "failure()": true, "job.status == 'failure'": true}},
{"main if ignores the failed job", func(job, _ *RunContext) { failStep(job) }, false, stepStageMain, map[string]bool{"success()": true, "failure()": false}},
{"main if reads the composite's own failure", func(_, composite *RunContext) { failStep(composite) }, false, stepStageMain, map[string]bool{"success()": false, "failure()": true}},
{"nested main if ignores the outer composite's failure", func(_, composite *RunContext) { failStep(composite) }, true, stepStageMain, map[string]bool{"success()": true}},
{"main if sees the cancelled job", func(job, _ *RunContext) { job.markCancelled() }, false, stepStageMain, map[string]bool{"cancelled()": true, "success()": false}},
{"post-if sees the cancelled job", func(job, _ *RunContext) { job.markCancelled() }, false, stepStagePost, map[string]bool{"cancelled()": true, "success()": false}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
job := makeTestRC(t, "runs-on: ubuntu-latest")
composite := newTestCompositeRunContext(t, job)
rc := composite
if tt.nested {
rc = newTestCompositeRunContext(t, composite)
}
tt.setup(job, composite)
step := &stepRun{RunContext: rc}
for expr, want := range tt.want {
enabled, err := isStepEnabled(t.Context(), expr, step, tt.stage)
require.NoError(t, err)
assert.Equal(t, want, enabled, expr)
}
})
}
}
func newTestCompositeRunContext(t *testing.T, parent *RunContext) *RunContext {
composite, err := newCompositeRunContext(t.Context(), parent, &stepActionRemote{
Step: &model.Step{},
RunContext: parent,
action: &model.Action{},
env: map[string]string{},
}, "/action")
require.NoError(t, err)
return composite
}
+14 -10
View File
@@ -242,7 +242,7 @@ func TestActionRunner(t *testing.T) {
tt.step.getRunContext().JobContainer = cm tt.step.getRunContext().JobContainer = cm
err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx) err := runActionImpl(tt.step, "dir", mustNewRemoteAction(t, "org/repo/path@ref"))(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cm.AssertExpectations(t) cm.AssertExpectations(t)
@@ -316,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
step.On("getStepModel").Return(&model.Step{ID: "action"}) step.On("getStepModel").Return(&model.Step{ID: "action"})
step.On("getEnv").Return(&env) step.On("getEnv").Return(&env)
_, _ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "") newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers. // DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
assert.Empty(t, captured.Username) assert.Empty(t, captured.Username)
@@ -461,18 +461,11 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
case <-innerEntered: case <-innerEntered:
case err := <-done: case err := <-done:
t.Fatalf("execAsDocker returned without entering inner: %v", err) t.Fatalf("execAsDocker returned without entering inner: %v", err)
case <-time.After(time.Second):
t.Fatal("inner build executor not entered after lock released")
} }
cancel() cancel()
releaseOnce() releaseOnce()
<-done
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
}
} }
func TestDockerActionImageTag(t *testing.T) { func TestDockerActionImageTag(t *testing.T) {
@@ -520,6 +513,17 @@ func TestDockerActionImageTag(t *testing.T) {
dockerActionImageTag("owner/repo", "./", true), dockerActionImageTag("owner/repo", "./", true),
dockerActionImageTag("owner/repo", "./sub", true), dockerActionImageTag("owner/repo", "./sub", true),
) )
workflow := &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}
job := newTestRC(workflow, nil)
step := &stepActionRemote{Step: &model.Step{}, RunContext: job}
image := job.dockerActionImage(t.Context(), step, "abc123", false)
assert.True(t, strings.HasPrefix(image, "act-abc123-dockeraction:latest-"))
assert.Equal(t, image, (&RunContext{Parent: job}).dockerActionImage(t.Context(), step, "abc123", false))
assert.NotEqual(t, image, job.dockerActionImage(t.Context(), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false))
assert.NotEqual(t, image, newTestRC(workflow, nil).dockerActionImage(t.Context(), step, "abc123", false))
job.dockerActionImage(common.WithDryrun(t.Context(), true), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false)
assert.Len(t, job.dockerActionImages, 2)
} }
func TestExecAsDockerStageEntrypoint(t *testing.T) { func TestExecAsDockerStageEntrypoint(t *testing.T) {
+4 -1
View File
@@ -14,6 +14,7 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -249,7 +250,8 @@ func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
// SetJobError dereferences a nil map and panics. The post context must therefore be detached // SetJobError dereferences a nil map and panics. The post context must therefore be detached
// from cancellation (so the steps run) yet still carry a usable error container. // from cancellation (so the steps run) yet still carry a usable error container.
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) { func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background())) jobSpan := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID{1}, SpanID: trace.SpanID{1}})
cancelled, cancel := context.WithCancel(trace.ContextWithSpanContext(common.WithJobErrorContainer(context.Background()), jobSpan))
cancel() cancel()
require.ErrorIs(t, cancelled.Err(), context.Canceled) require.ErrorIs(t, cancelled.Err(), context.Canceled)
@@ -258,6 +260,7 @@ func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
// Detached from cancellation, so the post steps actually run. // Detached from cancellation, so the post steps actually run.
require.NoError(t, postCtx.Err(), "post context must not be cancelled") require.NoError(t, postCtx.Err(), "post context must not be cancelled")
assert.Equal(t, jobSpan, trace.SpanContextFromContext(postCtx), "post steps must stay in the job's trace")
// A failing post step records its error instead of panicking. // A failing post step records its error instead of panicking.
require.NotPanics(t, func() { require.NotPanics(t, func() {
+53 -122
View File
@@ -13,7 +13,6 @@ import (
"reflect" "reflect"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -27,29 +26,6 @@ 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())
@@ -59,23 +35,9 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
var workflowCallResult map[string]*model.WorkflowCallResult var workflowCallResult map[string]*model.WorkflowCallResult
// todo: cleanup EvaluationEnvironment creation // todo: cleanup EvaluationEnvironment creation
using := make(map[string]exprparser.Needs)
strategy := make(map[string]any) strategy := make(map[string]any)
if rc.Run != nil { if rc.Run != nil {
job := rc.Run.Job() strategy = exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal)
if job != nil && job.Strategy != nil {
setStrategyContext(strategy, job.Strategy)
}
jobs := rc.Run.Workflow.Jobs
jobNeeds := rc.Run.Job().Needs()
for _, needs := range jobNeeds {
using[needs] = exprparser.Needs{
Outputs: jobs[needs].Outputs,
Result: jobs[needs].NeedsResult(),
}
}
// only setup jobs context in case of workflow_call // only setup jobs context in case of workflow_call
// and existing expression evaluator (this means, jobs are at // and existing expression evaluator (this means, jobs are at
@@ -83,7 +45,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
if rc.caller != nil && rc.ExprEval != nil { if rc.caller != nil && rc.ExprEval != nil {
workflowCallResult = map[string]*model.WorkflowCallResult{} workflowCallResult = map[string]*model.WorkflowCallResult{}
for jobName, job := range jobs { for jobName, job := range rc.Run.Workflow.Jobs {
result := model.WorkflowCallResult{ result := model.WorkflowCallResult{
Outputs: map[string]string{}, Outputs: map[string]string{},
} }
@@ -108,7 +70,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: strategy,
Matrix: rc.Matrix, Matrix: rc.Matrix,
Needs: using, Needs: exprparser.NeedsContext(rc.Run),
Inputs: inputs, Inputs: inputs,
HashFiles: getHashFilesFunction(ctx, rc), HashFiles: getHashFilesFunction(ctx, rc),
} }
@@ -127,43 +89,26 @@ var hashfiles string
// NewStepExpressionEvaluator creates a new evaluator with the `inputs` of the enclosing workflow or composite action // NewStepExpressionEvaluator creates a new evaluator with the `inputs` of the enclosing workflow or composite action
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator { func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
return rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs) return rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, rc.getJobContext())
} }
// NewActionInputsExpressionEvaluator creates a new evaluator with the step's own with: values as `inputs` // NewActionInputsExpressionEvaluator creates a new evaluator with the step's own with: values as `inputs`
func (rc *RunContext) NewActionInputsExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator { func (rc *RunContext) NewActionInputsExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
return rc.newStepExpressionEvaluator(ctx, step, inputsFromEnv(*step.getEnv())) return rc.newStepExpressionEvaluator(ctx, step, inputsFromEnv(*step.getEnv()), rc.getJobContext())
} }
func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any) *ExpressionEvaluator { func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any, jobContext *model.JobContext) *ExpressionEvaluator {
// todo: cleanup EvaluationEnvironment creation // todo: cleanup EvaluationEnvironment creation
job := rc.Run.Job()
strategy := make(map[string]any)
if job.Strategy != nil {
setStrategyContext(strategy, job.Strategy)
}
jobs := rc.Run.Workflow.Jobs
jobNeeds := rc.Run.Job().Needs()
using := make(map[string]exprparser.Needs)
for _, needs := range jobNeeds {
using[needs] = exprparser.Needs{
Outputs: jobs[needs].Outputs,
Result: jobs[needs].NeedsResult(),
}
}
ee := &exprparser.EvaluationEnvironment{ ee := &exprparser.EvaluationEnvironment{
Github: step.getGithubContext(ctx), Github: step.getGithubContext(ctx),
Env: *step.getEnv(), Env: *step.getEnv(),
Job: rc.getJobContext(), Job: jobContext,
Steps: rc.getStepsContext(), Steps: rc.getStepsContext(),
Secrets: getWorkflowSecrets(rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal),
Matrix: rc.Matrix, Matrix: rc.Matrix,
Needs: using, Needs: exprparser.NeedsContext(rc.Run),
// todo: should be unavailable // todo: should be unavailable
// but required to interpolate/evaluate the inputs in actions/composite // but required to interpolate/evaluate the inputs in actions/composite
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)), Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
@@ -301,50 +246,44 @@ func inputsFromEnv(env map[string]string) map[string]any {
} }
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any { func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow
return maps.Clone(stepInputs)
}
inputs := map[string]any{} inputs := map[string]any{}
maps.Copy(inputs, rc.workflowCallInputs) // only a called workflow's job itself takes the inputs Gitea resolved,
maps.Copy(inputs, stepInputs) // composite actions copy its Config but have their own `inputs`
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
if ghc.EventName == "workflow_dispatch" { maps.Copy(inputs, rc.Config.WorkflowCallInputs)
config := rc.Run.Workflow.WorkflowDispatchConfig() return inputs
if config != nil && config.Inputs != nil {
for k, v := range config.Inputs {
value := nestedMapLookup(ghc.Event, "inputs", k)
if value == nil {
value = v.Default
}
inputs[k] = coerceInputValue(value, v.Type)
}
}
} }
if ghc.EventName == "workflow_call" { maps.Copy(inputs, rc.workflowCallInputs)
config := rc.Run.Workflow.WorkflowCallConfig()
if config != nil && config.Inputs != nil { switch ghc.EventName {
for k, v := range config.Inputs { case "workflow_dispatch":
value := nestedMapLookup(ghc.Event, "inputs", k) if config := rc.Run.Workflow.WorkflowDispatchConfig(); config != nil {
if value == nil { for name, input := range config.Inputs {
value = v.Default if input.Type == "number" {
input.Type = "string" // boolean is the only typed dispatch input, as in Gitea
} }
inputs[k] = coerceInputValue(value, v.Type) setEvaluatorInput(inputs, ghc, name, model.WorkflowCallInput{Type: input.Type, Default: input.Default})
} }
} }
case "workflow_call":
for name, input := range rc.Run.Workflow.WorkflowCallConfig().Inputs {
setEvaluatorInput(inputs, ghc, name, input)
}
} }
return inputs return inputs
} }
// coerceInputValue converts an input value to the type declared by the workflow. func setEvaluatorInput(inputs map[string]any, ghc *model.GithubContext, name string, input model.WorkflowCallInput) {
// The event payload carries natively typed JSON values on newer Gitea versions, value := model.NestedMapLookup(ghc.Event, "inputs", name)
// while defaults and older servers provide strings. if value == nil {
func coerceInputValue(value any, inputType string) any { value, _ = input.DefaultValue()
if inputType != "boolean" {
return value
} }
if b, ok := value.(bool); ok { inputs[name] = model.CoerceInputValue(value, input.Type)
return b
}
return value == "true"
} }
// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow. // resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
@@ -352,40 +291,32 @@ func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
if rc.caller == nil { if rc.caller == nil {
return nil return nil
} }
logger := common.Logger(ctx)
callerJob := rc.caller.runContext.Run.Job() callerJob := rc.caller.runContext.Run.Job()
callerEval := rc.caller.runContext.ExprEval callerEval := rc.caller.runContext.ExprEval.shared(ctx)
callerWith := callerJob.With var callerWith map[string]any
if err := decodeDeferred(ctx, callerEval, "workflow inputs", callerJob.RawWith, &callerWith); err != nil { if err := model.DecodeEvaluated("workflow inputs", callerJob.RawWith, callerEval.EvaluateYamlNode, &callerWith); err != nil {
return err return err
} }
calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) }) if !rc.Run.Workflow.IsWorkflowCall() {
logger.Warn("workflow_call key is not defined in the referenced workflow")
}
config := rc.Run.Workflow.WorkflowCallConfig() config := rc.Run.Workflow.WorkflowCallConfig()
var err error
rc.workflowCallInputs = make(map[string]any, len(config.Inputs)) if rc.workflowCallInputs, err = callerEval.ResolveWorkflowCallInputs(config, callerWith); err != nil {
for name, input := range config.Inputs { logger.Warn(err)
value, eval, label := callerWith[name], callerEval, "input"
if value == nil {
value, eval, label = input.Default, calleeEval(), "the default of input"
}
if str, ok := value.(string); ok {
var err error
if value, err = eval.Interpolate(ctx, str); err != nil {
return fmt.Errorf("unable to interpolate %s %s: %w", label, name, err)
}
}
rc.workflowCallInputs[name] = coerceInputValue(value, input.Type)
} }
if callerJob.InheritSecrets() {
rc.workflowCallSecrets = getWorkflowSecrets(rc.caller.runContext)
return nil
}
secrets := callerJob.Secrets() secrets := callerJob.Secrets()
if secrets == nil && callerJob.InheritSecrets() { if err := config.ValidateSecrets(secrets); err != nil {
secrets = rc.caller.runContext.Config.Secrets logger.Warn(err)
} }
rc.workflowCallSecrets = make(map[string]string, len(secrets)) if rc.workflowCallSecrets, err = callerEval.EvaluateCallerSecrets(secrets); err != nil {
for k, v := range secrets { return fmt.Errorf("unable to interpolate %w", err)
var err error
if rc.workflowCallSecrets[k], err = callerEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate secret %s: %w", k, err)
}
} }
return nil return nil
} }
+94 -2
View File
@@ -294,6 +294,8 @@ on:
name: name:
type: string type: string
default: gitea default: gitea
unset:
type: boolean
`, `,
"workflow_dispatch": ` "workflow_dispatch": `
on: on:
@@ -305,6 +307,8 @@ on:
name: name:
type: string type: string
default: gitea default: gitea
unset:
type: boolean
`, `,
} }
@@ -356,6 +360,7 @@ on:
inputs := getEvaluatorInputs(rc, nil, ghc) inputs := getEvaluatorInputs(rc, nil, ghc)
assert.Equal(t, table.flag, inputs["flag"]) assert.Equal(t, table.flag, inputs["flag"])
assert.Equal(t, "gitea", inputs["name"]) assert.Equal(t, "gitea", inputs["name"])
assert.Equal(t, false, inputs["unset"])
}) })
} }
} }
@@ -375,11 +380,11 @@ jobs:
for _, value := range []string{"true", "false"} { for _, value := range []string{"true", "false"} {
t.Run(value, func(t *testing.T) { t.Run(value, func(t *testing.T) {
t.Parallel() t.Parallel()
parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"runner"}`}) 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) require.NoError(t, err)
child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil) child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, map[string]any{"flag": value == "true", "name": "runner"}, child.workflowCallInputs) assert.Equal(t, map[string]any{"flag": value == "true", "name": "${{ 'runner' }}", "unset": false}, child.workflowCallInputs)
assert.Equal(t, rawWith, job.RawWith) assert.Equal(t, rawWith, job.RawWith)
assert.Nil(t, job.With) assert.Nil(t, job.With)
}) })
@@ -387,6 +392,93 @@ jobs:
}) })
} }
func TestGetEvaluatorInputsResolvedByGitea(t *testing.T) {
wf, err := model.ReadWorkflow(strings.NewReader(`
on:
workflow_call:
inputs:
name: {type: string, default: callee}
workflow_dispatch:
inputs:
name: {type: string}
`))
require.NoError(t, err)
config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}}
ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}}
// the event's own inputs must not override those Gitea resolved
job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}}
assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc))
composite := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{}}, Parent: job}
assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc))
}
func TestActionInputsShadowDispatchInputs(t *testing.T) {
wf, err := model.ReadWorkflow(strings.NewReader(`
on:
workflow_dispatch:
inputs:
message: {type: string}
jobs:
job1: {}
`))
require.NoError(t, err)
rc := newTestRC(wf, nil)
rc.Config.EventName = "workflow_dispatch"
rc.EventJSON = `{"inputs":{"message":"workflow"}}`
step := &stepActionRemote{RunContext: rc, env: map[string]string{"INPUT_MESSAGE": "step"}}
assert.Equal(t, "step", rc.NewActionInputsExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
assert.Equal(t, "workflow", rc.NewStepExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
enabled, err := isStepEnabled(t.Context(), "inputs.message == 'workflow'", step, stepStageMain)
require.NoError(t, err)
assert.True(t, enabled)
step.Step, step.action = &model.Step{}, &model.Action{Inputs: map[string]model.Input{"fallback": {Default: "${{ inputs.message }}"}}}
env, err := evaluateCompositeInputAndEnv(t.Context(), rc, step)
require.NoError(t, err)
assert.Equal(t, "workflow", env["INPUT_FALLBACK"])
}
func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) {
callerWorkflow, err := model.ReadWorkflow(strings.NewReader(`
jobs:
call:
uses: ./reuse.yml
with: {flag: not-a-boolean}
secrets: {undeclared: "${{ secrets.token }}"}
inherit:
uses: ./reuse.yml
secrets: inherit
`))
require.NoError(t, err)
callee, err := model.ReadWorkflow(strings.NewReader(`
on:
workflow_call:
inputs:
flag: {type: boolean, default: true}
jobs:
test: {}
`))
require.NoError(t, err)
notCallable, err := model.ReadWorkflow(strings.NewReader("on: push\njobs:\n test: {}\n"))
require.NoError(t, err)
config := &Config{Secrets: map[string]string{"token": "${{ github.token }}"}}
resolve := func(callerJobID string, workflow *model.Workflow) *RunContext {
parent, err := (&runnerImpl{config: config}).newRunContext(t.Context(), &model.Run{Workflow: callerWorkflow, JobID: callerJobID}, nil)
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)
return child
}
called := resolve("call", callee)
assert.Equal(t, map[string]any{"flag": true}, called.workflowCallInputs)
assert.Equal(t, map[string]string{"undeclared": "${{ github.token }}"}, called.workflowCallSecrets)
inherited := resolve("inherit", notCallable)
assert.Empty(t, inherited.workflowCallInputs)
assert.Equal(t, config.Secrets, inherited.workflowCallSecrets)
}
func TestJobNameMasksSecrets(t *testing.T) { func TestJobNameMasksSecrets(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(` workflow, err := model.ReadWorkflow(strings.NewReader(`
jobs: jobs:
+11
View File
@@ -5,6 +5,7 @@ package runner
import ( import (
"context" "context"
"os"
"os/exec" "os/exec"
"runtime" "runtime"
"testing" "testing"
@@ -40,6 +41,16 @@ func requireDocker(t *testing.T) {
} }
} }
func requireDockerSharingFiles(t *testing.T) {
t.Helper()
requireDocker(t)
for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} {
if _, err := os.Stat(marker); err == nil {
t.Skip("skipping: inside a container, the docker daemon cannot bind-mount this process's files")
}
}
}
// requireHostTools skips the test unless every named executable is on PATH. Used by the // requireHostTools skips the test unless every named executable is on PATH. Used by the
// self-hosted (host environment) suite, which runs steps directly on the host. // self-hosted (host environment) suite, which runs steps directly on the host.
func requireHostTools(t *testing.T, tools ...string) { func requireHostTools(t *testing.T, tools ...string) {
+42 -40
View File
@@ -13,12 +13,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net/http" "net/http"
"path" "path"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
@@ -259,6 +259,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error())) logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
} }
setJobResult(ctx, info, rc, jobError == nil) setJobResult(ctx, info, rc, jobError == nil)
rc.removeDockerActionImages(ctx)
return err return err
}) })
@@ -282,8 +283,8 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the // finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
// returned context always carries a fresh bounded deadline and is never itself cancelled. // returned context always carries a fresh bounded deadline and is never itself cancelled.
// //
// - context.Canceled (server cancel): detach from the cancelled context via a fresh root so // - context.Canceled (server cancel): detach with WithoutCancel under a fresh job-error
// the post steps can run. // container, so a failing post step cannot turn the cancellation into a failure.
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which // - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
// keeps the original values — including the job-error container — so the timeout failure and // keeps the original values — including the job-error container — so the timeout failure and
// any post-step error are preserved and the job is still reported as failed. // any post-step error are preserved and the job is still reported as failed.
@@ -291,10 +292,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) { func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
switch ctx.Err() { switch ctx.Err() {
case context.Canceled: case context.Canceled:
// The cancelled context is abandoned for a fresh root, which drops the job-error return context.WithTimeout(common.WithJobErrorContainer(context.WithoutCancel(ctx)), 5*time.Minute)
// container installed at the job root. Re-attach a fresh one so a failing post step
// records its error via SetJobError instead of panicking on a nil container.
return context.WithTimeout(common.WithJobErrorContainer(common.WithLogger(context.Background(), common.Logger(ctx))), 5*time.Minute)
case context.DeadlineExceeded: case context.DeadlineExceeded:
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute) return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
default: default:
@@ -414,49 +412,53 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage) logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
} }
// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup. // evaluateJobEnv returns the workflow and job env evaluated, as far as it got on error, leaving rc.Env raw.
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error { func evaluateJobEnv(ctx context.Context, rc *RunContext) (map[string]string, error) {
rc.ExprEval = rc.NewExpressionEvaluator(ctx) env := rc.GetEnv()
evaluated := maps.Clone(env)
evaluator := rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
var workflowEnv map[string]string var workflowEnv map[string]string
if err := decodeDeferred(ctx, rc.ExprEval, "workflow env", rc.Run.Workflow.RawEnv, &workflowEnv); err != nil { if rc.Run.Workflow.Env == nil {
return err if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, evaluator.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
} return evaluated, err
if workflowEnv != nil {
rc.Env = mergeMaps(workflowEnv, rc.GetEnv())
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
}
var err error
for k, v := range rc.GetEnv() {
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
} }
} }
defaults := rc.Run.Job().Defaults.Run if workflowEnv != nil {
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil { evaluated = mergeMaps(workflowEnv, env)
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err) evaluator = rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
} }
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil { var err error
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err) for k, v := range env {
if evaluated[k], err = evaluator.Interpolate(ctx, v); err != nil {
return evaluated, fmt.Errorf("unable to interpolate env %s: %w", k, err)
}
} }
return nil return evaluated, nil
}
// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup.
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
env, err := evaluateJobEnv(ctx, rc)
rc.Env = env
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
if err != nil {
return err
}
var defaults model.Defaults
if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil {
return err
}
rc.jobRunDefaults = defaults.Run
return model.DecodeEvaluated("container env", rc.containerEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &rc.containerSpec.Env)
} }
func setJobOutputs(ctx context.Context, rc *RunContext) error { func setJobOutputs(ctx context.Context, rc *RunContext) error {
if rc.caller == nil { if rc.caller == nil {
return nil return nil
} }
outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs callerOutputs, err := rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateWorkflowCallOutputs(rc.Run.Workflow.WorkflowCallConfig())
callerOutputs := make(map[string]string, len(outputs)) if err != nil {
ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) }) return fmt.Errorf("unable to interpolate workflow %w", err)
for k, v := range outputs {
value := v.Value
for range 2 { // two passes, the value resolves through a job output
var err error
if value, err = ee().Interpolate(ctx, value); err != nil {
return fmt.Errorf("unable to interpolate workflow output %s: %w", k, err)
}
}
callerOutputs[k] = value
} }
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job; // Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
@@ -474,7 +476,7 @@ func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (conte
if err != nil { if err != nil {
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err) common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
} else if timeout != "" { } else if timeout != "" {
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil { if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeoutMinutes > 0 {
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute) return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
} }
} }
+2 -2
View File
@@ -479,8 +479,7 @@ func TestNewJobExecutor(t *testing.T) {
Config: &Config{}, Config: &Config{},
} }
if tt.output != "" { if tt.output != "" {
rc.Run.Job().Outputs = map[string]string{"bad": tt.output} require.NoError(t, rc.Run.Job().RawOutputs.Encode(map[string]string{"bad": tt.output}))
rc.outputTemplate = map[string]string{"bad": tt.output}
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
executorOrder := make([]string, 0) executorOrder := make([]string, 0)
@@ -1082,6 +1081,7 @@ func TestApplyJobTimeout(t *testing.T) {
}{ }{
{"empty", "runs-on: ubuntu-latest", false}, {"empty", "runs-on: ubuntu-latest", false},
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true}, {"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true},
{"zero ignored", "timeout-minutes: 0\nruns-on: ubuntu-latest", false},
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false}, {"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
} }
for _, tc := range cases { for _, tc := range cases {
+1 -6
View File
@@ -7,7 +7,6 @@ import (
"cmp" "cmp"
"context" "context"
"fmt" "fmt"
"maps"
"path" "path"
"strings" "strings"
@@ -51,11 +50,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
rawLogger.Infof("shell: %s", shell) rawLogger.Infof("shell: %s", shell)
} }
env := map[string]string{} env := mergeMaps(rc.containerSpec.Env, rc.GetEnv())
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
maps.Copy(env, jobContainer.Env)
}
maps.Copy(env, rc.GetEnv())
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env) rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
rc.ApplyExtraPath(ctx, &env) rc.ApplyExtraPath(ctx, &env)
+35 -82
View File
@@ -9,7 +9,6 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"path" "path"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -20,28 +19,22 @@ import (
) )
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor { func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
if !rc.Config.NoSkipCheckout { localWorkflow, err := model.ParseReusableWorkflowUses(rc.Run.Job().Uses)
fullPath := rc.Run.Job().Uses if err != nil {
return common.NewErrorExecutor(err)
fileName := path.Base(fullPath)
workflowDir := strings.TrimSuffix(fullPath, path.Join("/", fileName))
workflowDir = strings.TrimPrefix(workflowDir, "./")
return common.NewPipelineExecutor(
// resolve the local workflow against the workspace root, not the process
// working directory, so it is found regardless of where the runner is invoked
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
)
} }
// ./.gitea/workflows/wf.yml -> .gitea/workflows/wf.yml if !rc.Config.NoSkipCheckout {
trimmedUses := strings.TrimPrefix(rc.Run.Job().Uses, "./") // resolve the local workflow against the workspace root, not the process
// uses string format is {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref} // working directory, so it is found regardless of where the runner is invoked
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, trimmedUses, rc.Config.PresetGitHubContext.Sha) return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path)
}
remoteReusableWorkflow := newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses) uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
if remoteReusableWorkflow == nil {
return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses)) remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, uses)
if err != nil {
return common.NewErrorExecutor(err)
} }
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses)) workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
@@ -51,37 +44,27 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token), cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()), newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
) )
} }
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor { func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
uses := rc.Run.Job().Uses remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, rc.Run.Job().Uses)
if err != nil {
var remoteReusableWorkflow *remoteReusableWorkflow return common.NewErrorExecutor(err)
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
remoteReusableWorkflow = newRemoteReusableWorkflowFromAbsoluteURL(uses)
if remoteReusableWorkflow == nil {
return common.NewErrorExecutor(fmt.Errorf("expected format http(s)://{domain}/{owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
}
} else {
remoteReusableWorkflow = newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses)
if remoteReusableWorkflow == nil {
return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
}
} }
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref} // uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref}
// instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using // instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using
// multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again // multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref) filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Owner, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename)) workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL()) token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token), cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()), newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
) )
} }
@@ -90,7 +73,7 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
// //
// Callers must not change remoteReusableWorkflow.URL, because: // Callers must not change remoteReusableWorkflow.URL, because:
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env // 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat // 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflow
// //
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL // remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor { func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
@@ -153,64 +136,34 @@ func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) {
} }
type remoteReusableWorkflow struct { type remoteReusableWorkflow struct {
URL string *model.ReusableWorkflowUses
Org string URL string
Repo string
Filename string
Ref string
GitPlatform string
} }
func (r *remoteReusableWorkflow) CloneURL() string { func (r *remoteReusableWorkflow) CloneURL() string {
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case. // In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") { if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
return fmt.Sprintf("%s/%s/%s", r.URL, r.Org, r.Repo) return fmt.Sprintf("%s/%s/%s", r.URL, r.Owner, r.Repo)
} }
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Org, r.Repo) return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Owner, r.Repo)
} }
func (r *remoteReusableWorkflow) FilePath() string { var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
return fmt.Sprintf("./.%s/workflows/%s", r.GitPlatform, r.Filename)
}
// For Gitea // For Gitea
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow` // newRemoteReusableWorkflow parses a remote `uses`, an absolute URL up to `{owner}/{repo}/.{git_platform}/workflows/` replaces baseURL
// workflows from `.gitea/workflows` and `.github/workflows` are supported func newRemoteReusableWorkflow(baseURL, uses string) (*remoteReusableWorkflow, error) {
func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow { if matches := absoluteReusableWorkflowURLRegex.FindStringSubmatch(uses); matches != nil {
// GitHub docs: baseURL, uses = matches[1], matches[2]
// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses
r := regexp.MustCompile(`^([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`)
matches := r.FindStringSubmatch(uses)
if len(matches) != 6 {
return nil
} }
return &remoteReusableWorkflow{ parsed, err := model.ParseReusableWorkflowUses(uses)
Org: matches[1], if err != nil {
Repo: matches[2], return nil, err
GitPlatform: matches[3],
Filename: matches[4],
Ref: matches[5],
URL: url,
} }
} if parsed.IsLocal() {
return nil, fmt.Errorf("expected a remote reusable workflow, got %q", uses)
// For Gitea
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow` from an absolute url
func newRemoteReusableWorkflowFromAbsoluteURL(uses string) *remoteReusableWorkflow {
r := regexp.MustCompile(`^(https?://.*)/([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`)
matches := r.FindStringSubmatch(uses)
if len(matches) != 7 {
return nil
}
return &remoteReusableWorkflow{
URL: matches[1],
Org: matches[2],
Repo: matches[3],
GitPlatform: matches[4],
Filename: matches[5],
Ref: matches[6],
} }
return &remoteReusableWorkflow{ReusableWorkflowUses: parsed, URL: baseURL}, nil
} }
// For Gitea // For Gitea
+17
View File
@@ -5,6 +5,7 @@ package runner
import ( import (
"context" "context"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -106,6 +107,22 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
} }
} }
func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) {
workdir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644))
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
rc := &RunContext{
Config: &Config{Workdir: workdir},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
}
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
require.Error(t, err)
require.NotErrorIs(t, err, fs.ErrNotExist)
}
}
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) { func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
conf := &Config{ conf := &Config{
GitHubInstance: "gitea.example.net", GitHubInstance: "gitea.example.net",
+305 -294
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"archive/tar" "archive/tar"
"bufio" "bufio"
"cmp"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
@@ -18,11 +19,11 @@ import (
maps0 "maps" maps0 "maps"
"net" "net"
"os" "os"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices" "slices"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -36,8 +37,10 @@ import (
"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/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/kballard/go-shellquote"
"github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/mount"
"github.com/opencontainers/selinux/go-selinux" "github.com/opencontainers/selinux/go-selinux"
"go.yaml.in/yaml/v4"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"golang.org/x/text/encoding/unicode" "golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform" "golang.org/x/text/transform"
@@ -63,6 +66,9 @@ type RunContext struct {
ExprEval *expressionEvaluator ExprEval *expressionEvaluator
JobContainer container.ExecutionsEnvironment JobContainer container.ExecutionsEnvironment
serviceContainers []*serviceContainer serviceContainers []*serviceContainer
kubernetes bool
containerSpec model.ContainerSpec // container:, resolved once with platformImage
containerEnv yaml.Node // container.env of the kept containerSpec, decoded once the job env resolves
JobName string JobName string
ActionPath string ActionPath string
Parent *RunContext Parent *RunContext
@@ -75,6 +81,11 @@ type RunContext struct {
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
platformImage string // container.image or the runs-on pick, resolved once by isEnabled platformImage string // container.image or the runs-on pick, resolved once by isEnabled
jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown
jobEnv map[string]string // workflow and job env as evaluated for the container and service specs
dockerActionImages map[*model.Step]string // Dockerfile action images built for this job, removed when it ends
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md) // summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
// have already been created on the JobContainer. The runner sets up file-command files // have already been created on the JobContainer. The runner sets up file-command files
// via JobContainer.Copy at the start of every phase, which truncates them — fine for // via JobContainer.Copy at the start of every phase, which truncates them — fine for
@@ -83,10 +94,6 @@ type RunContext struct {
// so writes from later phases and from composite sub-steps append to the same file. // so writes from later phases and from composite sub-steps append to the same file.
// Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext. // Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext.
summaryFileInitialized map[int]bool summaryFileInitialized map[int]bool
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
// captured before execution so each matrix combo interpolates from the originals rather
// than from a sibling's already-resolved values written into the shared Job.Outputs.
outputTemplate map[string]string
// jobCancelled records that this job's run was cancelled (context.Canceled). It makes // jobCancelled records that this job's run was cancelled (context.Canceled). It makes
// getJobContext report the "cancelled" status so cancelled()/always() evaluate the way // getJobContext report the "cancelled" status so cancelled()/always() evaluate the way
// GitHub Actions does, letting cleanup and always() steps run while normal steps skip. // GitHub Actions does, letting cleanup and always() steps run while normal steps skip.
@@ -114,6 +121,7 @@ type serviceContainer struct {
name string name string
image string image string
container container.ExecutionsEnvironment container container.ExecutionsEnvironment
created bool
logsDumped bool logsDumped bool
info *container.Info // last poll, the source of the `job.services` entry info *container.Info // last poll, the source of the `job.services` entry
} }
@@ -252,6 +260,9 @@ const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cach
func (rc *RunContext) validVolumes() []string { func (rc *RunContext) validVolumes() []string {
name := rc.jobContainerName() name := rc.jobContainerName()
volumes := slices.Clone(rc.Config.ValidVolumes) volumes := slices.Clone(rc.Config.ValidVolumes)
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath())
}
if rc.Config.SharedToolCache { if rc.Config.SharedToolCache {
volumes = append(volumes, sharedToolCacheVolume) volumes = append(volumes, sharedToolCacheVolume)
} }
@@ -335,26 +346,12 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
} }
// Returns the binds and mounts for the container, resolving paths as appopriate // Returns the binds and mounts for the container, resolving paths as appopriate
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) { func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string) {
name := rc.jobContainerName() name := rc.jobContainerName()
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
var volumes []string
if job := rc.Run.Job(); job != nil {
if container := job.Container(); container != nil {
for _, v := range container.Volumes {
if rc.ExprEval != nil {
var err error
if v, err = rc.ExprEval.Interpolate(context.Background(), v); err != nil {
return nil, nil, fmt.Errorf("unable to interpolate container.volumes: %w", err)
}
}
volumes = append(volumes, v)
}
}
}
// the runner's own mounts below yield to the targets the job claims // the runner's own mounts below yield to the targets the job claims
binds, mounts, claimed := splitVolumes(volumes) binds, mounts, claimed := splitVolumes(container.SanitizeBinds(ctx, rc.validVolumes(), rc.containerSpec.Volumes))
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] { if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock") binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
@@ -364,6 +361,9 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
mounts[sharedToolCacheVolume] = toolCache mounts[sharedToolCacheVolume] = toolCache
} }
} }
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
binds = append(binds, hostEnv.Path+":"+hostEnv.Path, hostEnv.GetActPath()+":"+hostEnv.GetActPath())
}
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] {
@@ -386,7 +386,7 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
} }
} }
return binds, mounts, nil return binds, mounts
} }
func (rc *RunContext) startHostEnvironment() common.Executor { func (rc *RunContext) startHostEnvironment() common.Executor {
@@ -430,11 +430,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
} }
rc.cleanUpJobContainer = rc.JobContainer.Remove() rc.cleanUpJobContainer = rc.JobContainer.Remove()
for k, v := range rc.getRunnerContext(ctx) { rc.setRunnerContextEnv(ctx)
if v, ok := v.(string); ok {
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
}
}
for _, env := range os.Environ() { for _, env := range os.Environ() {
if k, v, ok := strings.Cut(env, "="); ok { if k, v, ok := strings.Cut(env, "="); ok {
// don't override // don't override
@@ -444,16 +440,70 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
} }
} }
return rc.copyWorkflowFiles()(ctx)
}
}
func (rc *RunContext) setRunnerContextEnv(ctx context.Context) {
for k, v := range rc.getRunnerContext(ctx) {
if v, ok := v.(string); ok {
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
}
}
}
var errKubernetesDocker = errors.New("docker actions and docker:// steps are not supported on Kubernetes")
func (rc *RunContext) startKubernetesPod() common.Executor {
return func(ctx context.Context) error {
if rc.containerSpec.Options != "" || len(rc.containerSpec.Volumes) > 0 || rc.containerSpec.Credentials != nil {
return errors.New("container options, volumes and credentials are not supported on Kubernetes")
}
var options container.KubernetesOptions
if rc.Config.KubernetesPicker != nil {
options = rc.Config.KubernetesPicker(rc.runsOnPlatformNames(ctx))
}
options.MaxLifetime, options.ForcePull = rc.Config.ContainerMaxLifetime, rc.Config.ForcePull
pod, err := container.NewKubernetesPod(options)
if err != nil {
return err
}
services, err := rc.jobServices(ctx, "")
if err != nil {
return err
}
for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
input := services[serviceID]
if input.Username != "" || len(input.Binds) > 0 || len(input.Mounts) > 0 {
return fmt.Errorf("service %s: volumes and credentials are not supported on Kubernetes", serviceID)
}
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: input.Image, container: pod.ServiceContainer(serviceID, input)})
}
logWriter := rc.commandLogWriter(ctx)
rc.JobContainer = pod.JobContainer(&container.NewContainerInput{
Entrypoint: []string{"tail", "-f", "/dev/null"},
WorkingDir: (&container.LinuxContainerEnvironmentExtensions{}).ToContainerPath(rc.Config.Workdir),
Image: rc.platformImage,
Name: rc.jobContainerName(),
Env: []string{"LANG=C.UTF-8"},
Stdout: logWriter,
Stderr: logWriter,
})
rc.cleanUpJobContainer = func(ctx context.Context) error {
rc.printServiceLogs(ctx)
return rc.JobContainer.Remove()(ctx)
}
defer printStartJobContainerGroup(ctx, rc.platformImage, rc.jobContainerName(), "")()
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", &container.FileEntry{ rc.JobContainer.Create(nil, nil),
Name: "workflow/event.json", rc.startServiceContainers(),
Mode: 0o644, rc.JobContainer.Start(false),
Body: rc.EventJSON, func(ctx context.Context) error { rc.setRunnerContextEnv(ctx); return nil },
}, &container.FileEntry{ rc.captureJobContainerInfo(),
Name: "workflow/envs.txt", rc.copyWorkflowFiles(),
Mode: 0o666, rc.reportUnstartedServices(),
Body: "", rc.waitForServiceContainers(),
}),
)(ctx) )(ctx)
} }
} }
@@ -483,11 +533,10 @@ func WithJobVolumeCleanup(ctx context.Context, deferCleanup func(common.Executor
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)) rc.deferVolumeCleanup, _ = ctx.Value(jobVolumeCleanupKey{}).(func(common.Executor))
logger := common.Logger(ctx)
image := rc.platformImage image := rc.platformImage
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
username, password, err := rc.handleCredentials(ctx) username, password, err := registryCredentials(rc.containerSpec.Credentials, "container.")
if err != nil { if err != nil {
return fmt.Errorf("failed to handle credentials: %s", err) return fmt.Errorf("failed to handle credentials: %s", err)
} }
@@ -496,6 +545,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
// For gitea, to support --volumes-from <container_name_or_id> in options. // For gitea, to support --volumes-from <container_name_or_id> in options.
// We need to set the container name to the environment variable. // We need to set the container name to the environment variable.
rc.Env["JOB_CONTAINER_NAME"] = name rc.Env["JOB_CONTAINER_NAME"] = name
delete(rc.jobEnv, "JOB_CONTAINER_NAME") // the runtime name wins over a workflow env of the same name
envList := make([]string, 0) envList := make([]string, 0)
@@ -508,109 +558,22 @@ func (rc *RunContext) startJobContainer() common.Executor {
networkName, createAndDeleteNetwork := rc.networkNameForGitea() networkName, createAndDeleteNetwork := rc.networkNameForGitea()
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false) rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false)
services := rc.Run.Job().Services services, err := rc.jobServices(ctx, networkName)
if err := decodeDeferred(ctx, rc.ExprEval, "job services", rc.Run.Job().RawServices, &services); err != nil { if err != nil {
return err return err
} }
for serviceID, spec := range services { for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
// GitHub compatibility: skip services whose image evaluates to an input := services[serviceID]
// empty string, enabling conditional services via expressions rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: input.Image, container: newContainer(input)})
serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image)
if err != nil {
return fmt.Errorf("unable to interpolate service %s image: %w", serviceID, err)
}
if serviceImage == "" {
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue
}
// interpolate env
interpolatedEnvs := make(map[string]string, len(spec.Env)+len(rc.Config.ProxyEnv))
// a service reaches the internet the way the job does; its own env still wins
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
for k, v := range spec.Env {
if interpolatedEnvs[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate service %s env %s: %w", serviceID, k, err)
}
}
envs := make([]string, 0, len(interpolatedEnvs))
for k, v := range interpolatedEnvs {
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
}
// interpolate cmd
interpolatedCmd := make([]string, len(spec.Cmd))
for i, v := range spec.Cmd {
if interpolatedCmd[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate service %s command: %w", serviceID, err)
}
}
// keep these local: reusing username/password would overwrite the
// credentials the job container is pulled with further down
serviceUsername, servicePassword, err := rc.interpolateCredentials(ctx, spec.Credentials, "")
if err != nil {
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
}
interpolatedVolumes := make([]string, len(spec.Volumes))
for i, volume := range spec.Volumes {
if interpolatedVolumes[i], err = rc.ExprEval.Interpolate(ctx, volume); err != nil {
return fmt.Errorf("unable to interpolate service %s volumes: %w", serviceID, err)
}
}
serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes)
interpolatedPorts := make([]string, len(spec.Ports))
for i, port := range spec.Ports {
if interpolatedPorts[i], err = rc.ExprEval.Interpolate(ctx, port); err != nil {
return fmt.Errorf("unable to interpolate service %s ports: %w", serviceID, err)
}
}
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
if err != nil {
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
}
serviceOptions, err := rc.ExprEval.Interpolate(ctx, spec.Options)
if err != nil {
return fmt.Errorf("unable to interpolate service %s options: %w", serviceID, err)
}
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := newContainer(&container.NewContainerInput{
Name: serviceContainerName,
Image: serviceImage,
Username: serviceUsername,
Password: servicePassword,
Cmd: interpolatedCmd,
Env: envs,
Mounts: serviceMounts,
Binds: serviceBinds,
Stdout: logWriter,
Stderr: logWriter,
Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
WorkflowOptions: serviceOptions,
NetworkMode: networkName,
NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts,
PortBindings: portBindings,
ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket
AllocatePTY: rc.Config.AllocatePTY,
})
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c})
} }
// For Gitea, `jobContainerNetwork` should be the same as `networkName` // For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName jobContainerNetwork := networkName
workflowOptions, err := rc.workflowOptions(ctx)
if err != nil {
return err
}
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
containerInput := &container.NewContainerInput{ containerInput := &container.NewContainerInput{
Cmd: nil, Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, Entrypoint: []string{"sleep", strconv.FormatInt(int64(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds()), 10)},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir), WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: image, Image: image,
Username: username, Username: username,
@@ -625,7 +588,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
RunnerOptions: rc.Config.ContainerOptions, RunnerOptions: rc.Config.ContainerOptions,
WorkflowOptions: workflowOptions, WorkflowOptions: rc.containerSpec.Options,
AutoRemove: true, AutoRemove: true,
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
} }
@@ -642,9 +605,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
return err return err
} }
rc.startDockerProxy(ctx) rc.startDockerProxy(ctx)
if containerInput.Binds, containerInput.Mounts, err = rc.GetBindsAndMounts(); err != nil { containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts(ctx)
return err
}
containerInput.ValidVolumes = rc.validVolumes() containerInput.ValidVolumes = rc.validVolumes()
rc.jobNetworkName = networkName rc.jobNetworkName = networkName
@@ -652,37 +613,110 @@ func (rc *RunContext) startJobContainer() common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions). container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
IfBool(createAndDeleteNetwork), IfBool(createAndDeleteNetwork),
container.NewDockerNetworkConnectExecutor(networkName, rc.Config.CacheContainer).
IfBool(rc.Config.CacheContainer != ""),
rc.startServiceContainers(), rc.startServiceContainers(),
rc.reportUnstartedServices(), rc.reportUnstartedServices(),
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) }, 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(),
rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", &container.FileEntry{ rc.copyWorkflowFiles(),
Name: "workflow/event.json",
Mode: 0o644,
Body: rc.EventJSON,
}, &container.FileEntry{
Name: "workflow/envs.txt",
Mode: 0o666,
Body: "",
}),
rc.waitForServiceContainers(), rc.waitForServiceContainers(),
)(ctx) )(ctx)
} }
} }
func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[string]*container.NewContainerInput, error) {
var services map[string]*model.ContainerSpec
if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.NewExpressionEvaluatorWithEnv(ctx, mergeMaps(rc.GetEnv(), rc.jobEnv)).shared(ctx).EvaluateYamlNode, &services); err != nil {
return nil, err
}
inputs := map[string]*container.NewContainerInput{}
for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
spec := services[serviceID]
if spec == nil {
return nil, fmt.Errorf("service %s has no container definition", serviceID)
}
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
// GitHub compatibility: skip services whose image evaluates to an
// empty string, enabling conditional services via expressions
if spec.Image == "" {
common.Logger(ctx).Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue
}
envs := make([]string, 0, len(spec.Env)+len(rc.Config.ProxyEnv))
// a service reaches the internet the way the job does; its own env still wins
for k, v := range mergeMaps(rc.Config.ProxyEnv, spec.Env) {
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
}
serviceUsername, servicePassword, err := registryCredentials(spec.Credentials, "")
if err != nil {
return nil, fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
}
cmd := spec.Cmd
if len(cmd) == 0 && spec.Command != "" {
if cmd, err = shellquote.Split(spec.Command); err != nil {
return nil, fmt.Errorf("failed to parse service %s command: %w", serviceID, err)
}
}
var entrypoint []string
if spec.Entrypoint != "" {
entrypoint = []string{spec.Entrypoint}
}
serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes)
exposedPorts, portBindings, err := nat.ParsePortSpecs(spec.Ports)
if err != nil {
return nil, fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
}
serviceLog := common.NewLineWriter(rawLogLine(ctx)) // raw, so a service's output cannot set the job's env, outputs or state
inputs[serviceID] = &container.NewContainerInput{
Name: createContainerName(rc.jobContainerName(), serviceID),
Image: spec.Image,
Username: serviceUsername,
Password: servicePassword,
Entrypoint: entrypoint,
Cmd: cmd,
Env: envs,
Mounts: serviceMounts,
Binds: serviceBinds,
Stdout: serviceLog,
Stderr: serviceLog,
Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
WorkflowOptions: spec.Options,
NetworkMode: networkName,
NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts,
PortBindings: portBindings,
ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket
AllocatePTY: rc.Config.AllocatePTY,
}
}
return inputs, nil
}
func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer { func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
return common.NewLineWriter(rc.commandHandler(ctx), rawLogLine(ctx))
}
func rawLogLine(ctx context.Context) common.LineHandler {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true) rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
return common.NewLineWriter(rc.commandHandler(ctx), func(line string) bool { return func(line string) bool {
rawLogger.Infof("%s", line) rawLogger.Infof("%s", line)
return true return true
}) }
} }
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork, preclean 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)
rc.printServiceLogs(ctx)
errs := []error{rc.closeDockerProxy(ctx)} errs := []error{rc.closeDockerProxy(ctx)}
if rc.JobContainer != nil { if rc.JobContainer != nil {
errs = append(errs, rc.JobContainer.Remove()(ctx)) errs = append(errs, rc.JobContainer.Remove()(ctx))
@@ -701,6 +735,8 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
} }
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)
errs = append(errs, container.NewDockerNetworkDisconnectExecutor(networkName, rc.Config.CacheContainer).
IfBool(rc.Config.CacheContainer != "")(ctx))
errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx)) errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
} }
return errors.Join(errs...) return errors.Join(errs...)
@@ -811,6 +847,14 @@ func (rc *RunContext) stopJobContainer() common.Executor {
} }
} }
func (rc *RunContext) removeDockerActionImages(ctx context.Context) {
for _, image := range rc.dockerActionImages {
if _, err := container.RemoveImage(ctx, image, true, false); err != nil {
common.Logger(ctx).Warnf("failed to remove action image %s: %v", image, err)
}
}
}
func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor { func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
execs := []common.Executor{} execs := []common.Executor{}
@@ -827,6 +871,7 @@ func (rc *RunContext) startServiceContainers() common.Executor {
for _, svc := range rc.serviceContainers { for _, svc := range rc.serviceContainers {
execs = append(execs, common.NewPipelineExecutor( execs = append(execs, common.NewPipelineExecutor(
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
func(context.Context) error { svc.created = true; return nil },
svc.container.Start(false), svc.container.Start(false),
)) ))
} }
@@ -965,6 +1010,26 @@ func (svc *serviceContainer) dumpLogs(ctx context.Context) {
} }
} }
const serviceLogsTimeout = 15 * time.Second // cleanup gets one minute in total
func (rc *RunContext) printServiceLogs(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, serviceLogsTimeout)
defer cancel()
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
for _, svc := range rc.serviceContainers {
if !svc.created || svc.logsDumped {
continue
}
rawLogger.Infof("::group::Print service container logs: %s", EscapeCommandData(svc.name))
svc.dumpLogs(ctx)
rawLogger.Infof("::endgroup::")
if ctx.Err() != nil {
common.Logger(ctx).Warnf("Timed out printing the service logs after %s", serviceLogsTimeout)
return
}
}
}
// inspect also records the state for the `job.services` context. // inspect also records the state for the `job.services` context.
func (svc *serviceContainer) inspect(ctx context.Context) (*container.Info, error) { func (svc *serviceContainer) inspect(ctx context.Context) (*container.Info, error) {
info, err := svc.container.Inspect(ctx) info, err := svc.container.Inspect(ctx)
@@ -982,6 +1047,13 @@ func (svc *serviceContainer) healthOutputSuffix() string {
return ": " + svc.info.HealthOutput return ": " + svc.info.HealthOutput
} }
func (rc *RunContext) copyWorkflowFiles() common.Executor {
return rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/",
&container.FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: rc.EventJSON},
&container.FileEntry{Name: "workflow/envs.txt", Mode: 0o666},
)
}
// captureJobContainerInfo is a convenience: failing to describe the container must not // captureJobContainerInfo is a convenience: failing to describe the container must not
// fail the job. // fail the job.
func (rc *RunContext) captureJobContainerInfo() common.Executor { func (rc *RunContext) captureJobContainerInfo() common.Executor {
@@ -995,12 +1067,8 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
if rc.dockerProxy != nil { if rc.dockerProxy != nil {
rc.dockerProxy.SetMounts(info.Mounts) rc.dockerProxy.SetMounts(info.Mounts)
} }
workspace := rc.githubWorkspace() if source := info.DaemonPath(rc.githubWorkspace()); source != "" {
for dir := workspace; dir != "/" && dir != "."; dir = path.Dir(dir) { rc.Env["GITEA_DOCKER_WORKSPACE"] = source
if source := info.Mounts[dir]; source != "" {
rc.Env["GITEA_DOCKER_WORKSPACE"] = path.Join(source, strings.TrimPrefix(workspace, dir))
break
}
} }
return nil return nil
} }
@@ -1038,48 +1106,28 @@ func lockJob(job *model.Job) func() {
func (rc *RunContext) interpolateOutputs() common.Executor { func (rc *RunContext) interpolateOutputs() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
ee := rc.NewExpressionEvaluator(ctx)
job := rc.Run.Job() job := rc.Run.Job()
// Matrix combinations share this Job and its Outputs map. Interpolate from this combo's var outputs map[string]string
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites if err := model.DecodeEvaluated("job outputs", job.RawOutputs, rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateYamlNode, &outputs); err != nil {
// 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 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 return err
} }
if deferredOutputs != nil { // Matrix combinations share the job, so as on GitHub a failure publishes nothing and the last non-empty value wins.
defer lockJob(job)() maps0.DeleteFunc(outputs, func(_, value string) bool { return value == "" })
job.Outputs = deferredOutputs
return nil
}
outputs := make(map[string]string, len(rc.outputTemplate))
var err error
for k, v := range rc.outputTemplate {
if outputs[k], err = ee.Interpolate(ctx, v); err != nil {
err = fmt.Errorf("failed to evaluate job output %q: %w", k, err)
break
}
}
defer lockJob(job)() defer lockJob(job)()
for k := range rc.outputTemplate { job.Outputs = mergeMaps(job.Outputs, outputs)
if err != nil { return nil
job.Outputs[k] = ""
continue
}
job.Outputs[k] = outputs[k]
}
return err
} }
} }
func (rc *RunContext) startContainer() common.Executor { func (rc *RunContext) startContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
var err error var err error
if rc.IsHostEnv() { switch {
case rc.kubernetes:
err = rc.startKubernetesPod()(ctx)
case rc.IsHostEnv():
err = rc.startHostEnvironment()(ctx) err = rc.startHostEnvironment()(ctx)
} else { default:
err = rc.startJobContainer()(ctx) err = rc.startJobContainer()(ctx)
} }
if err != nil { if err != nil {
@@ -1104,7 +1152,7 @@ func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
} }
func (rc *RunContext) IsHostEnv() bool { func (rc *RunContext) IsHostEnv() bool {
return strings.EqualFold(rc.platformImage, "-self-hosted") return rc.containerSpec.Image == "" && strings.EqualFold(rc.platformImage, "-self-hosted")
} }
func (rc *RunContext) stopContainer() common.Executor { func (rc *RunContext) stopContainer() common.Executor {
@@ -1173,9 +1221,7 @@ func (rc *RunContext) Executor() (common.Executor, error) {
return func(ctx context.Context) error { return func(ctx context.Context) error {
res, err := rc.isEnabled(ctx) res, err := rc.isEnabled(ctx)
if err != nil { if err != nil {
// Record the failure so a job whose if-expression fails to evaluate // Record the failure so a job that cannot start gets a stop time. rc.caller is only set for reusable workflows.
// gets a result (and therefore a stop time) instead of being left
// unfinished. rc.caller is only set for reusable workflows.
rc.result("failure") rc.result("failure")
if rc.caller != nil { // For Gitea if rc.caller != nil { // For Gitea
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "failure") rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "failure")
@@ -1189,20 +1235,8 @@ func (rc *RunContext) Executor() (common.Executor, error) {
}, nil }, nil
} }
func (rc *RunContext) containerImage(ctx context.Context) (string, error) {
c := rc.Run.Job().Container()
if c == nil {
return "", nil
}
image, err := rc.ExprEval.Interpolate(ctx, c.Image)
if err != nil {
return "", fmt.Errorf("unable to interpolate container.image: %w", err)
}
return image, nil
}
func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) { func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
if rc.Run.Job().RunsOn() == nil { if rc.Run.Job().RunsOn() == nil && rc.containerSpec.Image == "" {
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String()) common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
} }
@@ -1238,26 +1272,55 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
return model.RunsOnFromNode(rawRunsOn) return model.RunsOnFromNode(rawRunsOn)
} }
// resolvePlatformImage evaluates the job's image once, so every consumer sees the image the job started with. // resolvePlatformImage evaluates the job's container once for every consumer, skipping one whose image is empty as GitHub does.
func (rc *RunContext) resolvePlatformImage(ctx context.Context) error { func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
image, err := rc.containerImage(ctx) rc.jobEnv, _ = evaluateJobEnv(ctx, rc) // a broken env fails the job at setup instead
if err == nil && image == "" { withoutEnv, env := splitContainerEnv(rc.Run.Job().RawContainer)
image, err = rc.runsOnImage(ctx) var spec model.ContainerSpec
if err := model.DecodeEvaluated("container", withoutEnv, rc.NewExpressionEvaluatorWithEnv(ctx, rc.jobEnv).shared(ctx).EvaluateYamlNode, &spec); err != nil {
return err
} }
rc.platformImage = image spec.Image = strings.TrimPrefix(spec.Image, "docker://")
return err if spec.Image != "" || imagelessContainer(withoutEnv) {
rc.containerSpec, rc.containerEnv = spec, env
}
image, err := rc.runsOnImage(ctx)
if err != nil {
return err
}
image, rc.kubernetes = strings.CutPrefix(image, "kubernetes://")
rc.platformImage = cmp.Or(rc.containerSpec.Image, image)
return nil
} }
func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) { // imagelessContainer reports a container: mapping without an image key, which configures the runs-on image.
c := rc.Run.Job().Container() func imagelessContainer(container yaml.Node) bool {
if c == nil { if container.Kind != yaml.MappingNode {
return "", nil return false
} }
options, err := rc.ExprEval.Interpolate(ctx, c.Options) for i := 0; i < len(container.Content); i += 2 {
if err != nil { if container.Content[i].Value == "image" {
return "", fmt.Errorf("unable to interpolate container.options: %w", err) return false
}
} }
return options, nil return true
}
// splitContainerEnv takes `env` out of a container mapping, since env reads the job env that resolves after the job environment starts.
func splitContainerEnv(container yaml.Node) (withoutEnv, env yaml.Node) {
if container.Kind != yaml.MappingNode {
return container, env
}
withoutEnv = container
withoutEnv.Content = nil
for i := 0; i+1 < len(container.Content); i += 2 {
if container.Content[i].Value == "env" {
env = *container.Content[i+1]
} else {
withoutEnv.Content = append(withoutEnv.Content, container.Content[i:i+2]...)
}
}
return withoutEnv, env
} }
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) { func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
@@ -1267,7 +1330,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
jobType, jobTypeErr := job.Type() jobType, jobTypeErr := job.Type()
if runJobErr != nil { if runJobErr != nil {
return false, fmt.Errorf("if-expression %q evaluation failed: %s", job.If.Value, runJobErr) l.Errorf("Error when evaluating 'if' for job '%s'. %v", rc.Run.JobID, runJobErr)
} }
if jobType == model.JobTypeInvalid { if jobType == model.JobTypeInvalid {
@@ -1344,21 +1407,26 @@ func trimToLen(s string, l int) string {
return s return s
} }
func (rc *RunContext) getJobContext() *model.JobContext { // ownStatus is the result of this context's own steps, which for a composite action is its action status
jobStatus := "success" func (rc *RunContext) ownStatus() string {
if rc.jobFailed { if rc.jobFailed {
jobStatus = "failure" return "failure"
} }
for _, stepStatus := range rc.StepResults { for _, stepStatus := range rc.StepResults {
if stepStatus.Conclusion == model.StepStatusFailure { if stepStatus.Conclusion == model.StepStatusFailure {
jobStatus = "failure" return "failure"
break
} }
} }
return "success"
}
func (rc *RunContext) getJobContext() *model.JobContext {
job := rc.topLevelRunContext()
jobStatus := job.ownStatus()
// A cancelled run takes precedence over success/failure so cancelled() is true and // A cancelled run takes precedence over success/failure so cancelled() is true and
// success()/failure() are false, matching GitHub Actions: on cancellation only // success()/failure() are false, matching GitHub Actions: on cancellation only
// always() and cancelled() steps run. // always() and cancelled() steps run.
if rc.jobCancelled { if job.jobCancelled {
jobStatus = "cancelled" jobStatus = "cancelled"
} }
@@ -1366,17 +1434,17 @@ func (rc *RunContext) getJobContext() *model.JobContext {
Status: jobStatus, Status: jobStatus,
Services: map[string]model.JobService{}, // an empty map, never null Services: map[string]model.JobService{}, // an empty map, never null
} }
if rc.jobContainerID != "" { if job.jobContainerID != "" {
jobContext.Container.ID = rc.jobContainerID jobContext.Container.ID = job.jobContainerID
jobContext.Container.Network = rc.jobNetworkName jobContext.Container.Network = job.jobNetworkName
} }
for _, svc := range rc.serviceContainers { for _, svc := range job.serviceContainers {
if svc.info == nil { if svc.info == nil {
continue continue
} }
jobContext.Services[svc.name] = model.JobService{ jobContext.Services[svc.name] = model.JobService{
ID: svc.info.ID, ID: svc.info.ID,
Network: rc.jobNetworkName, Network: job.jobNetworkName,
Ports: svc.info.Ports, Ports: svc.info.Ports,
} }
} }
@@ -1471,22 +1539,11 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
{ // Adapt to Gitea { // Adapt to Gitea
if preset := rc.Config.PresetGitHubContext; preset != nil { if preset := rc.Config.PresetGitHubContext; preset != nil {
ghc.Event = preset.Event merged := *preset
ghc.RunID = preset.RunID merged.Workflow, merged.Job, merged.Workspace, merged.EventPath = ghc.Workflow, ghc.Job, ghc.Workspace, ghc.EventPath
ghc.RunNumber = preset.RunNumber merged.Action, merged.ActionPath, merged.ActionRepository, merged.ActionRef = ghc.Action, ghc.ActionPath, ghc.ActionRepository, ghc.ActionRef
ghc.RunAttempt = preset.RunAttempt merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
ghc.Actor = preset.Actor *ghc = merged
ghc.Repository = preset.Repository
ghc.EventName = preset.EventName
ghc.Sha = preset.Sha
ghc.Ref = preset.Ref
ghc.RefName = preset.RefName
ghc.RefType = preset.RefType
ghc.HeadRef = preset.HeadRef
ghc.BaseRef = preset.BaseRef
ghc.Token = preset.Token
ghc.RepositoryOwner = preset.RepositoryOwner
ghc.RetentionDays = preset.RetentionDays
instance := rc.Config.GitHubInstance instance := rc.Config.GitHubInstance
if !strings.HasPrefix(instance, "http://") && if !strings.HasPrefix(instance, "http://") &&
@@ -1563,12 +1620,7 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
if step.Type() != model.StepTypeUsesActionRemote { if step.Type() != model.StepTypeUsesActionRemote {
return false return false
} }
remoteAction := newRemoteAction(step.Uses) if uses, err := model.ParseActionUses(step.Uses); err != nil || uses.Owner != "actions" || uses.Repo != "checkout" {
if remoteAction == nil {
// IsCheckout() will nil panic if we dont bail out early
return false
}
if !remoteAction.IsCheckout() {
return false return false
} }
@@ -1581,23 +1633,6 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
return true return true
} }
func nestedMapLookup(m map[string]any, ks ...string) (rval any) {
var ok bool
if len(ks) == 0 { // degenerate input
return nil
}
if rval, ok = m[ks[0]]; !ok {
return nil
} else if len(ks) == 1 { // we've reached the final key
return rval
} else if m, ok = rval.(map[string]any); !ok {
return nil
} else { // 1+ more keys
return nestedMapLookup(m, ks[1:]...)
}
}
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) { func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
env["CI"] = "true" env["CI"] = "true"
env["GITHUB_WORKFLOW"] = github.Workflow env["GITHUB_WORKFLOW"] = github.Workflow
@@ -1721,41 +1756,17 @@ func setActionRuntimeVars(rc *RunContext, env map[string]string) {
env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken
} }
func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) { func registryCredentials(credentials map[string]string, prefix string) (string, string, error) {
container := rc.Run.Job().Container()
if container == nil || container.Credentials == nil {
return "", "", nil
}
return rc.interpolateCredentials(ctx, container.Credentials, "container.")
}
func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials map[string]string, prefix string) (string, string, error) {
if credentials == nil { if credentials == nil {
return "", "", nil return "", "", nil
} }
if len(credentials) != 2 { if len(credentials) != 2 {
return "", "", errors.New("invalid property count for key 'credentials:'") return "", "", errors.New("invalid property count for key 'credentials:'")
} }
for _, key := range []string{"username", "password"} {
ee := rc.NewExpressionEvaluator(ctx) if credentials[key] == "" {
interpolate := func(key string) (string, error) { return "", "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
value, err := ee.Interpolate(ctx, credentials[key])
if err != nil {
return "", fmt.Errorf("failed to interpolate %scredentials.%s: %w", prefix, key, err)
} }
if value == "" {
return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
}
return value, nil
} }
username, err := interpolate("username") return credentials["username"], credentials["password"], nil
if err != nil {
return "", "", err
}
password, err := interpolate("password")
if err != nil {
return "", "", err
}
return username, password, nil
} }
+171 -96
View File
@@ -32,6 +32,7 @@ import (
"github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/loader"
"github.com/moby/moby/api/types/volume" "github.com/moby/moby/api/types/volume"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test"
assert "github.com/stretchr/testify/assert" assert "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
require "github.com/stretchr/testify/require" require "github.com/stretchr/testify/require"
@@ -185,38 +186,6 @@ func TestRunContext_EvalBool(t *testing.T) {
} }
} }
func TestRunContextHandleCredentialsDoesNotUseDockerSecrets(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(`
name: test
on: push
jobs:
job:
runs-on: ubuntu-latest
steps: []
`))
require.NoError(t, err)
rc := &RunContext{
Config: &Config{
Secrets: map[string]string{
"DOCKER_USERNAME": "docker-user",
"DOCKER_PASSWORD": "docker-password",
},
Env: map[string]string{},
},
Run: &model.Run{
JobID: "job",
Workflow: workflow,
},
}
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit job container pull credentials.
username, password, err := rc.handleCredentials(t.Context())
require.NoError(t, err)
assert.Empty(t, username)
assert.Empty(t, password)
}
// fakeContainer turns every container operation into a no-op, so startJobContainer // fakeContainer turns every container operation into a no-op, so startJobContainer
// runs without a Docker daemon. The embedded interface is nil, so any method the // runs without a Docker daemon. The embedded interface is nil, so any method the
// test does not exercise panics rather than silently doing the wrong thing. // test does not exercise panics rather than silently doing the wrong thing.
@@ -282,7 +251,6 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
rc := &RunContext{ rc := &RunContext{
Name: "test", Name: "test",
Config: cfg, Config: cfg,
Env: map[string]string{},
Run: &model.Run{ Run: &model.Run{
JobID: "job", JobID: "job",
Workflow: workflow, Workflow: workflow,
@@ -370,19 +338,52 @@ jobs:
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts) require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
require.Empty(t, redis.Binds) // the docker socket is the job container's alone require.Empty(t, redis.Binds) // the docker socket is the job container's alone
require.Empty(t, redis.WorkingDir) require.Empty(t, redis.WorkingDir)
require.Nil(t, redis.Entrypoint)
require.Nil(t, redis.Cmd)
} }
// A whole-value `services:` expression only reaches the typed field through DecodeRaw. func TestStartJobContainerBuildsEvaluatedJobAndServiceInputs(t *testing.T) {
func TestStartJobContainerGivesServicesTheirVolumesFromExpression(t *testing.T) { inputs := startJobContainerInputs(t, `
redis := startJobContainerInputs(t, `
jobs: jobs:
job: job:
services: ${{ fromJSON('{"redis":{"image":"redis:latest","volumes":["data:/data"]}}') }} container: ${{ fromJSON('{"image":"docker://node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }}
`, &Config{ValidVolumes: []string{"data"}})[0] services: ${{ fromJSON('{"redis":{"image":"docker://redis:latest","volumes":["data:/data"],"env":{"TITLE":"${{ github.job }}"},"credentials":{"username":"user","password":"${{ github.job }}"},"entrypoint":"/entry.sh","command":"redis-server --port 6380"}}') }}
`, &Config{})
redis, job := inputs[0], inputs[1]
require.Equal(t, "redis:latest", redis.Image) require.Equal(t, "redis:latest", redis.Image)
require.Equal(t, []string{"data"}, redis.ValidVolumes)
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts) require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
require.Equal(t, []string{"TITLE=${{ github.job }}"}, redis.Env)
require.Equal(t, "${{ github.job }}", redis.Password)
require.Equal(t, []string{"/entry.sh"}, redis.Entrypoint)
require.Equal(t, []string{"redis-server", "--port", "6380"}, redis.Cmd)
require.Equal(t, "node:20", job.Image)
require.Equal(t, "sleep", job.Entrypoint[0])
require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions)
require.Equal(t, "${{ vars.TITLE }}", job.Password)
inputs = startJobContainerInputs(t, `
env:
REG_USER: ${{ vars.REG_USER }}
TEXT: ${{ vars.TEXT }}
JOB_CONTAINER_NAME: from-workflow
jobs:
job:
container:
image: node:20
credentials: {username: "${{ env.REG_USER }}", password: pass}
services:
redis:
image: redis:latest
credentials: {username: "${{ env.REG_USER }}", password: pass}
env: {TEXT: "${{ env.TEXT }}"}
options: --volumes-from ${{ env.JOB_CONTAINER_NAME }}
`, &Config{Vars: map[string]string{"REG_USER": "user", "TEXT": "${{ 'literal' }}"}})
redis, job = inputs[0], inputs[1]
require.Equal(t, "user", job.Username)
require.Equal(t, "user", redis.Username)
require.Equal(t, []string{"TEXT=${{ 'literal' }}"}, redis.Env)
require.Equal(t, "--volumes-from "+job.Name, redis.WorkflowOptions)
} }
// 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
@@ -435,17 +436,14 @@ jobs:
steps: [] steps: []
`, &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}}) `, &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}})
env := map[string][]string{} db, redis := inputs[0], inputs[1]
for _, in := range inputs {
env[in.Image] = in.Env
}
require.Contains(t, env["redis:latest"], "http_proxy=http://proxy:3128") require.Contains(t, redis.Env, "http_proxy=http://proxy:3128")
require.Contains(t, env["redis:latest"], "no_proxy=internal.example") require.Contains(t, redis.Env, "no_proxy=internal.example")
// the service's own env wins over what the runner injected, without dropping the rest // the service's own env wins over what the runner injected, without dropping the rest
require.Contains(t, env["postgres:latest"], "no_proxy=db-only.example") require.Contains(t, db.Env, "no_proxy=db-only.example")
require.NotContains(t, env["postgres:latest"], "no_proxy=internal.example") require.NotContains(t, db.Env, "no_proxy=internal.example")
require.Contains(t, env["postgres:latest"], "http_proxy=http://proxy:3128") require.Contains(t, db.Env, "http_proxy=http://proxy:3128")
} }
// act builds Dockerfile actions through the API, which does not pre-populate the proxy // act builds Dockerfile actions through the API, which does not pre-populate the proxy
@@ -506,8 +504,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
config := testcase.rc.Config config := testcase.rc.Config
config.Workdir = testcase.name config.Workdir = testcase.name
config.BindWorkdir = bindWorkDir config.BindWorkdir = bindWorkDir
gotbind, gotmount, err := rctemplate.GetBindsAndMounts() gotbind, gotmount := rctemplate.GetBindsAndMounts(t.Context())
require.NoError(t, err)
// Name binds/mounts are either/or // Name binds/mounts are either/or
if config.BindWorkdir { if config.BindWorkdir {
@@ -543,8 +540,9 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
t.Run("InterpolatedContainerVolumes", func(t *testing.T) { t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
job := &model.Job{} job := &model.Job{}
err := job.RawContainer.Encode(map[string][]string{ err := job.RawContainer.Encode(map[string]any{
"volumes": {"${{ secrets.MAME }}:/root/.mame/roms:ro"}, "image": "node:20",
"volumes": []string{"${{ secrets.MAME }}:/root/.mame/roms:ro"},
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -556,7 +554,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
}, },
}, },
Config: &Config{ Config: &Config{
BindWorkdir: false, BindWorkdir: false,
ValidVolumes: []string{"**"},
Secrets: map[string]string{ Secrets: map[string]string{
"MAME": "/host/mame/roms", "MAME": "/host/mame/roms",
}, },
@@ -565,9 +564,9 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.Run.JobID = "job1" rc.Run.JobID = "job1"
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
rc.ExprEval = rc.NewExpressionEvaluator(context.Background()) rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
require.NoError(t, rc.resolvePlatformImage(context.Background()))
gotbind, gotmount, err := rc.GetBindsAndMounts() gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
require.NoError(t, err)
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro") assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
assert.NotContains(t, gotbind, "${{ secrets.MAME }}") assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
assert.NotContains(t, gotmount, "${{ secrets.MAME }}") assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
@@ -575,12 +574,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
for _, testcase := range tests { for _, testcase := range tests {
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
job := &model.Job{}
err := job.RawContainer.Encode(map[string][]string{
"volumes": testcase.volumes,
})
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
rc := &RunContext{ rc := &RunContext{
Name: "TestRCName", Name: "TestRCName",
Run: &model.Run{ Run: &model.Run{
@@ -591,13 +584,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
Config: &Config{ Config: &Config{
BindWorkdir: false, BindWorkdir: false,
SharedToolCache: true, // so OverridesToolCache has a mount to displace SharedToolCache: true, // so OverridesToolCache has a mount to displace
ValidVolumes: []string{"**"},
}, },
containerSpec: model.ContainerSpec{Volumes: testcase.volumes},
} }
rc.Run.JobID = "job1" rc.Run.JobID = "job1"
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
gotbind, gotmount, err := rc.GetBindsAndMounts() gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
require.NoError(t, err)
if len(testcase.wantbind) > 0 { if len(testcase.wantbind) > 0 {
assert.Contains(t, gotbind, testcase.wantbind) assert.Contains(t, gotbind, testcase.wantbind)
@@ -632,13 +625,12 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
Config: &Config{}, Config: &Config{},
} }
_, gotmount, err := rc.GetBindsAndMounts() _, gotmount := rc.GetBindsAndMounts(t.Context())
require.NoError(t, err)
assert.NotContains(t, gotmount, sharedToolCacheVolume) assert.NotContains(t, gotmount, sharedToolCacheVolume)
rc.Config.SharedToolCache = true rc.Config.SharedToolCache = true
_, gotmount, err = rc.GetBindsAndMounts() rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
require.NoError(t, err) _, gotmount = rc.GetBindsAndMounts(t.Context())
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
}) })
@@ -648,21 +640,18 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
} }
rc := &RunContext{ rc := &RunContext{
Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}}, 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{}}, Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}, ValidVolumes: []string{"claimed"}},
} }
gotbind, _, err := rc.GetBindsAndMounts() gotbind, _ := rc.GetBindsAndMounts(t.Context())
require.NoError(t, err)
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind) assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
rc.Config.BindWorkdir = false rc.Config.BindWorkdir = false
_, gotmount, err := rc.GetBindsAndMounts() _, gotmount := rc.GetBindsAndMounts(t.Context())
require.NoError(t, err)
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()]) assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
require.NoError(t, rc.Run.Job().RawContainer.Encode(map[string][]string{"volumes": {"claimed:/workspace/1"}})) rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
_, gotmount, err = rc.GetBindsAndMounts() _, gotmount = rc.GetBindsAndMounts(t.Context())
require.NoError(t, err)
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()]) assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
}) })
} }
@@ -695,6 +684,15 @@ func TestRunContextValidVolumes(t *testing.T) {
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir) assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
rc.Config.PresetGitHubContext = &model.GithubContext{} rc.Config.PresetGitHubContext = &model.GithubContext{}
assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1")) assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1"))
hostEnv := &container.HostEnvironment{Path: t.TempDir(), ActPath: t.TempDir()}
rc.JobContainer = hostEnv
rc.Config.BindWorkdir = false
rc.Config.SharedToolCache = true
binds, mounts := rc.GetBindsAndMounts(t.Context())
assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()})
assert.Contains(t, mounts, sharedToolCacheVolume)
assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds))
} }
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) { func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
@@ -763,6 +761,20 @@ func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) {
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes))) assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
} }
func TestRemoveDockerActionImagesByTag(t *testing.T) {
var removed []string
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodDelete {
removed = append(removed, strings.TrimPrefix(request.URL.Path, "/v1.47/images/"))
_, _ = io.WriteString(writer, "[]")
return
}
_, _ = io.WriteString(writer, `{"Id":"sha256:shared"}`)
})
(&RunContext{dockerActionImages: map[*model.Step]string{{}: "act-action-dockeraction:latest-job"}}).removeDockerActionImages(t.Context())
assert.Equal(t, []string{"act-action-dockeraction:latest-job"}, removed)
}
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) { func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
t.Setenv("TMPDIR", "/tmp") t.Setenv("TMPDIR", "/tmp")
proxyDir := t.TempDir() proxyDir := t.TempDir()
@@ -778,7 +790,7 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
service.On("Remove").Return(func(context.Context) error { return removeError }).Once() service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
service.On("Close").Return(func(context.Context) error { return closeError }).Once() service.On("Close").Return(func(context.Context) error { return closeError }).Once()
rc := &RunContext{ rc := &RunContext{
Config: &Config{}, Config: &Config{CacheContainer: "cache-container"},
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"}, Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
JobContainer: job, JobContainer: job,
serviceContainers: []*serviceContainer{{name: "svc", container: service}}, serviceContainers: []*serviceContainer{{name: "svc", container: service}},
@@ -829,7 +841,7 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
if name == "canceled" { if name == "canceled" {
require.ErrorIs(t, err, context.Canceled) require.ErrorIs(t, err, context.Canceled)
} else { } else {
for _, operation := range []string{"DELETE /volumes/" + rc.jobContainerName(), "DELETE /volumes/" + rc.jobContainerName() + "-env", "GET /networks"} { for _, operation := range []string{"DELETE /volumes/" + rc.jobContainerName(), "DELETE /volumes/" + rc.jobContainerName() + "-env", "POST /networks/job-network/disconnect", "GET /networks"} {
require.ErrorContains(t, err, operation) require.ErrorContains(t, err, operation)
} }
assert.Equal(t, 2, volumeRemovals) assert.Equal(t, 2, volumeRemovals)
@@ -845,25 +857,30 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
} }
} }
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one func TestInterpolateOutputsIsPerMatrixComboKeepingNeedsOutputs(t *testing.T) {
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
// combo's resolved value freezes the shared template and later combos can't resolve their own.
func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}} job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}}
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}} require.NoError(t, job.RawOutputs.Encode(job.Outputs))
needed := &model.Job{Outputs: map[string]string{"o": "from-gitea"}}
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job, "needed": needed}}}
r := &runnerImpl{config: &Config{}} r := &runnerImpl{config: &Config{}}
ctx := context.Background() ctx := context.Background()
_, err := r.newRunContext(ctx, &model.Run{JobID: "needed", Workflow: run.Workflow}, nil)
require.NoError(t, err)
rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"}) rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"})
require.NoError(t, err) require.NoError(t, err)
rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"}) rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"})
require.NoError(t, err) require.NoError(t, err)
rcEmpty, err := r.newRunContext(ctx, run, map[string]any{"v": ""})
require.NoError(t, err)
require.Empty(t, job.Outputs)
require.NoError(t, rcA.interpolateOutputs()(ctx)) require.NoError(t, rcA.interpolateOutputs()(ctx))
require.NoError(t, rcB.interpolateOutputs()(ctx)) require.NoError(t, rcB.interpolateOutputs()(ctx))
require.NoError(t, rcEmpty.interpolateOutputs()(ctx))
// Last combo wins (matching GitHub) instead of being frozen to combo A's "a". require.Equal(t, map[string]string{"o": "b"}, job.Outputs)
require.Equal(t, "b", job.Outputs["o"]) require.Equal(t, map[string]string{"o": "from-gitea"}, needed.Outputs)
} }
// A whole-value `outputs:` expression only reaches the typed field through DecodeRaw. // A whole-value `outputs:` expression only reaches the typed field through DecodeRaw.
@@ -936,6 +953,12 @@ func TestGetGitHubContext(t *testing.T) {
assert.Equal(t, ghc.RunnerPerflog, "/dev/null") //nolint:testifylint // pre-existing issue from nektos/act assert.Equal(t, ghc.RunnerPerflog, "/dev/null") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, ghc.Token, rc.Config.Secrets["GITHUB_TOKEN"]) assert.Equal(t, ghc.Token, rc.Config.Secrets["GITHUB_TOKEN"])
assert.Equal(t, ghc.Job, "job1") //nolint:testifylint // pre-existing issue from nektos/act assert.Equal(t, ghc.Job, "job1") //nolint:testifylint // pre-existing issue from nektos/act
rc.Config.PresetGitHubContext = &model.GithubContext{Actor: "preset-actor", TriggeringActor: "triggerer", Job: "preset-job"}
ghc = rc.getGithubContext(context.Background())
assert.Equal(t, "preset-actor", ghc.Actor)
assert.Equal(t, "triggerer", ghc.TriggeringActor)
assert.Equal(t, "job1", ghc.Job)
} }
func TestGetGithubContextRef(t *testing.T) { func TestGetGithubContextRef(t *testing.T) {
@@ -982,7 +1005,6 @@ func TestGetGithubContextRef(t *testing.T) {
func createIfTestRunContext(jobs map[string]*model.Job) *RunContext { func createIfTestRunContext(jobs map[string]*model.Job) *RunContext {
rc := &RunContext{ rc := &RunContext{
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }}, Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }},
Env: map[string]string{},
Run: &model.Run{ Run: &model.Run{
JobID: "job1", JobID: "job1",
Workflow: &model.Workflow{ Workflow: &model.Workflow{
@@ -1059,9 +1081,12 @@ func TestRunContextIsEnabled(t *testing.T) {
// success() // success()
rc := createIfTestRunContext(map[string]*model.Job{ rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest "job1": createJob(t, `runs-on: ubuntu-latest
env:
BROKEN: ${{ fromJSON('invalid') }}
if: success()`, ""), if: success()`, ""),
}) })
assertObject.True(rc.isEnabled(context.Background())) assertObject.True(rc.isEnabled(context.Background()))
require.Error(t, evaluateJobEnvAndDefaults(context.Background(), rc))
rc = createIfTestRunContext(map[string]*model.Job{ rc = createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"), "job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
@@ -1165,6 +1190,13 @@ if: always()`, ""),
if: false`, ""), if: false`, ""),
}) })
assertObject.False(rc.isEnabled(context.Background())) assertObject.False(rc.isEnabled(context.Background()))
rc = createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `if: ${{ fromJSON('not-json') }}`, ""),
})
enabled, err := rc.isEnabled(context.Background())
require.NoError(t, err)
assertObject.False(enabled)
} }
func TestRunContextGetEnv(t *testing.T) { func TestRunContextGetEnv(t *testing.T) {
@@ -1289,12 +1321,13 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
} }
for _, testcase := range []struct { for _, testcase := range []struct {
name, health string name, health string
pullError error pullError, startError error
}{ }{
{name: "healthy", health: container.HealthHealthy}, {name: "healthy", health: container.HealthHealthy},
{name: "unhealthy", health: container.HealthUnhealthy}, {name: "unhealthy", health: container.HealthUnhealthy},
{name: "pull failure", pullError: errors.New("pull failed")}, {name: "pull failure", pullError: errors.New("pull failed")},
{name: "start failure", startError: errors.New("start failed")},
} { } {
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
var operations []string var operations []string
@@ -1308,7 +1341,7 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
for name, instance := range map[string]*containerMock{"job": job, "service": service} { 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("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("Create", mock.Anything, mock.Anything).Return(record(name+".Create", nil))
instance.On("Start", false).Return(record(name+".Start", nil)) instance.On("Start", false).Return(record(name+".Start", map[string]error{"service": testcase.startError}[name]))
instance.On("Remove").Return(record(name+".Remove", nil)) instance.On("Remove").Return(record(name+".Remove", nil))
instance.On("Close").Return(record(name+".Close", nil)) instance.On("Close").Return(record(name+".Close", nil))
} }
@@ -1319,10 +1352,14 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
service.On("Inspect", mock.Anything).Return(&container.Info{State: "running", Health: health}, nil). service.On("Inspect", mock.Anything).Return(&container.Info{State: "running", Health: health}, nil).
Run(func(mock.Arguments) { operations = append(operations, "service.Inspect") }).Once() Run(func(mock.Arguments) { operations = append(operations, "service.Inspect") }).Once()
} }
service.On("DumpLogs", mock.Anything).Return(nil). stdouts := map[string]io.Writer{}
Run(func(mock.Arguments) { operations = append(operations, "service.DumpLogs") }) service.On("DumpLogs", mock.Anything).Return(nil).Run(func(mock.Arguments) {
operations = append(operations, "service.DumpLogs")
fmt.Fprintln(stdouts["postgres:latest"], "::add-mask::leak")
})
origNewContainer := newContainer origNewContainer := newContainer
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment { newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
stdouts[input.Image] = input.Stdout
return map[string]*containerMock{"postgres:latest": service, "node:20": job}[input.Image] return map[string]*containerMock{"postgres:latest": service, "node:20": job}[input.Image]
} }
t.Cleanup(func() { newContainer = origNewContainer }) t.Cleanup(func() { newContainer = origNewContainer })
@@ -1333,22 +1370,30 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
Run: &model.Run{JobID: "job", Workflow: workflow}, Run: &model.Run{JobID: "job", Workflow: workflow},
platformImage: "node:20", platformImage: "node:20",
} }
ctx := common.WithDryrun(t.Context(), true) logger, hook := logrustest.NewNullLogger()
ctx := common.WithLogger(common.WithDryrun(t.Context(), true), logger)
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx) err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx)
want := "job.Remove service.Remove service.Close service.Pull job.Pull" want := "job.Remove service.Remove service.Close service.Pull job.Pull"
if testcase.pullError != nil { switch {
case testcase.pullError != nil:
require.ErrorIs(t, err, testcase.pullError) require.ErrorIs(t, err, testcase.pullError)
} else { case testcase.startError != nil:
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect" require.ErrorIs(t, err, testcase.startError)
want += " service.Create service.Start service.DumpLogs"
default:
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect service.DumpLogs"
if testcase.health == container.HealthUnhealthy { if testcase.health == container.HealthUnhealthy {
require.ErrorContains(t, err, "the service 'postgres' is unhealthy") require.ErrorContains(t, err, "the service 'postgres' is unhealthy")
want += " service.DumpLogs"
} else { } else {
require.NoError(t, err) require.NoError(t, err)
} }
} }
assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations) assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations)
assert.Empty(t, rc.Masks)
assert.Equal(t, testcase.pullError == nil && testcase.health != container.HealthUnhealthy, slices.ContainsFunc(hook.AllEntries(), func(entry *log.Entry) bool {
return strings.HasPrefix(entry.Message, "::group::Print service container logs")
}))
}) })
} }
@@ -1604,12 +1649,42 @@ func TestImageOSFromImage(t *testing.T) {
} }
} }
func TestResolvePlatformImageAppliesImagelessContainerToRunsOnImage(t *testing.T) {
for _, tc := range []struct {
name, image string
want model.ContainerSpec
}{
{"without image key", "", model.ContainerSpec{Volumes: []string{"/host/data:/data"}, Env: map[string]string{"KEY": "value"}}},
{"with empty image", "image: '', ", model.ContainerSpec{}},
} {
t.Run(tc.name, func(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, "runs-on: toolkit\ncontainer: {"+tc.image+"volumes: [/host/data:/data], env: {KEY: value}}", ""),
})
require.NoError(t, rc.resolvePlatformImage(t.Context()))
require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc))
assert.Equal(t, "ubuntu-latest", rc.platformImage)
assert.Equal(t, tc.want, rc.containerSpec)
})
}
}
func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext { func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext {
return createIfTestRunContext(map[string]*model.Job{ return createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, "runs-on: "+runsOn, ""), "job1": createJob(t, "runs-on: "+runsOn, ""),
}) })
} }
func TestRunContextContainerImageCannotSelectBackend(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{"job1": createJob(t, "container: -self-hosted", "")})
require.NoError(t, rc.resolvePlatformImage(t.Context()))
assert.False(t, rc.IsHostEnv())
rc = createIfTestRunContext(map[string]*model.Job{"job1": createJob(t, "runs-on: ${{ fromJSON('x') }}\ncontainer: node", "")})
require.ErrorContains(t, rc.resolvePlatformImage(t.Context()), "runs-on")
}
func TestRunContextImageOS(t *testing.T) { func TestRunContextImageOS(t *testing.T) {
ctx := context.Background() ctx := context.Background()
+32 -10
View File
@@ -7,7 +7,6 @@ package runner
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"os" "os"
"runtime" "runtime"
"sync" "sync"
@@ -15,6 +14,7 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/ghcontext"
"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"
@@ -57,10 +57,12 @@ type Config struct {
DisableActEnv bool // do not inject the ACT=true environment variable into jobs DisableActEnv bool // do not inject the ACT=true environment variable into jobs
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network) ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
CacheContainer string // let jobs reach an otherwise isolated cache
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
NoActionPatch bool // run actions exactly as published, applying no compatibility patches, see patch_actions.go NoActionPatch bool // run actions exactly as published, applying no compatibility patches, see patch_actions.go
PresetGitHubContext *model.GithubContext // overrides actor, ref, repository, token and related context fields PresetGitHubContext *model.GithubContext // overrides actor, ref, repository, token and related context fields
WorkflowCallInputs map[string]any // the `inputs` Gitea resolved for a called workflow's job, nil for other jobs
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
ContainerNamePrefix string // the prefix of container name ContainerNamePrefix string // the prefix of container name
ContainerMaxLifetime time.Duration // the max lifetime of job containers ContainerMaxLifetime time.Duration // the max lifetime of job containers
@@ -73,6 +75,7 @@ type Config struct {
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance. // AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
DefaultActionInstanceIsSelfHosted bool DefaultActionInstanceIsSelfHosted bool
PlatformPicker func(labels []string) string PlatformPicker func(labels []string) string
KubernetesPicker func(labels []string) container.KubernetesOptions
JobLoggerLevel *log.Level // the level of job logger JobLoggerLevel *log.Level // the level of job logger
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
SharedToolCache bool // one tool cache for all jobs instead of one per job SharedToolCache bool // one tool cache for all jobs instead of one per job
@@ -159,9 +162,9 @@ func (runner *runnerImpl) configure() (*runnerImpl, error) {
func maxParallelFor(strategy *model.Strategy, combinations int) int { func maxParallelFor(strategy *model.Strategy, combinations int) int {
maxParallel := 4 // actionslib has no default for an undeclared max-parallel maxParallel := 4 // actionslib has no default for an undeclared max-parallel
if strategy != nil { if strategy != nil {
if limit, declared, err := strategy.ParseMaxParallel(); err != nil { if limit, err := strategy.MaxParallel(); err != nil {
log.Errorf("Ignoring invalid max-parallel: %v", err) log.Errorf("Ignoring invalid max-parallel: %v", err)
} else if declared { } else if limit > 0 {
maxParallel = limit maxParallel = limit
} }
} }
@@ -210,7 +213,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
return err return err
} }
if job.RawStrategy.Kind == yaml.ScalarNode { if job.RawStrategy.Kind == yaml.ScalarNode {
if err := decodeDeferred(ctx, strategyRc.ExprEval, "job strategy", job.RawStrategy, &job.Strategy); err != nil { if err := model.DecodeEvaluated("job strategy", job.RawStrategy, strategyRc.ExprEval.shared(ctx).EvaluateYamlNode, &job.Strategy); err != nil {
return err return err
} }
} else { } else {
@@ -222,6 +225,11 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil { if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
log.Errorf("Error while evaluating matrix: %v", err) log.Errorf("Error while evaluating matrix: %v", err)
} }
for _, value := range []*string{&job.Strategy.FailFastString, &job.Strategy.MaxParallelString} {
if evaluated, err := strategyRc.ExprEval.Interpolate(ctx, *value); err == nil {
*value = evaluated
}
}
} }
} }
@@ -236,7 +244,16 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
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))
for i, matrix := range matrixes { for i, matrix := range matrixes {
rc, err := runner.newRunContext(ctx, run, matrix) index, total := i, len(matrixes)
if runner.caller == nil && runner.config.PresetGitHubContext != nil && len(matrix) > 0 {
var expanded struct {
Index int `yaml:"job-index"`
Total int `yaml:"job-total"`
}
_ = job.RawStrategy.Decode(&expanded) // Gitea expanded the combination, a version writing neither leaves them unknown
index, total = expanded.Index, expanded.Total
}
rc, err := runner.newCombinationRunContext(ctx, run, matrix, index, total)
if err != nil { if err != nil {
return err return err
} }
@@ -308,7 +325,8 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
}) })
} }
return common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan)) executor := common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
return func(ctx context.Context) error { return executor(ghcontext.WithPinnedCheckout(ctx)) }
} }
func handleFailure(plan *model.Plan) common.Executor { func handleFailure(plan *model.Plan) common.Executor {
@@ -325,6 +343,10 @@ func handleFailure(plan *model.Plan) common.Executor {
} }
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) { func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) {
return runner.newCombinationRunContext(ctx, run, matrix, 0, 1)
}
func (runner *runnerImpl) newCombinationRunContext(ctx context.Context, run *model.Run, matrix map[string]any, index, total int) (*RunContext, error) {
rc := &RunContext{ rc := &RunContext{
Config: runner.config, Config: runner.config,
Run: run, Run: run,
@@ -332,16 +354,16 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
StepResults: make(map[string]*model.StepResult), StepResults: make(map[string]*model.StepResult),
Matrix: matrix, Matrix: matrix,
caller: runner.caller, caller: runner.caller,
jobIndex: index,
jobTotal: total,
} }
if err := rc.resolveWorkflowCall(ctx); err != nil { if err := rc.resolveWorkflowCall(ctx); err != nil {
return nil, err return nil, err
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String())) rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String()))
// Snapshot the job's pristine output expressions now, before any matrix combo runs and if job := run.Job(); job != nil && job.RawOutputs.Kind != 0 {
// rewrites the shared Job.Outputs (see interpolateOutputs). job.Outputs = nil // a job no combination ran for reads "" as on GitHub, a Gitea needs placeholder has no RawOutputs and keeps its outputs
if job := run.Job(); job != nil {
rc.outputTemplate = maps.Clone(job.Outputs)
} }
return rc, nil return rc, nil
+9 -3
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/rand"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -29,6 +30,7 @@ var (
logLevel = log.DebugLevel logLevel = log.DebugLevel
workdir = "testdata" workdir = "testdata"
secrets map[string]string secrets map[string]string
testRunID = strings.ToLower(rand.Text()[:8]) // keeps docker names apart from concurrent test processes on one daemon
) )
func mapPlatformPicker(platforms map[string]string) func([]string) string { func mapPlatformPicker(platforms map[string]string) func([]string) string {
@@ -216,10 +218,9 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
EventPath: cfg.EventPath, EventPath: cfg.EventPath,
PlatformPicker: mapPlatformPicker(j.platforms), PlatformPicker: mapPlatformPicker(j.platforms),
// fixtures reuse workflow and job names, so parallel tests would collide without this // fixtures reuse workflow and job names, so parallel tests would collide without this
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-"), ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-") + "-" + testRunID,
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent // 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
MaxParallel: 2, MaxParallel: 2,
ForceRebuild: true,
Env: cfg.Env, Env: cfg.Env,
Secrets: cfg.Secrets, Secrets: cfg.Secrets,
GitHubInstance: "github.com", GitHubInstance: "github.com",
@@ -346,7 +347,7 @@ func TestRunEvent(t *testing.T) {
config := &Config{ config := &Config{
Secrets: table.secrets, Secrets: table.secrets,
Env: map[string]string{"GITHUB_REPOSITORY": t.Name()}, Env: map[string]string{"GITHUB_REPOSITORY": t.Name() + "-" + testRunID},
} }
eventFile := filepath.Join(workdir, table.workflowPath, "event.json") eventFile := filepath.Join(workdir, table.workflowPath, "event.json")
@@ -432,6 +433,9 @@ func TestRunEventHostEnvironment(t *testing.T) {
tables = append(tables, []TestJobFileInfo{ tables = append(tables, []TestJobFileInfo{
{workdir, "nix-prepend-path", "push", "", platforms, secrets}, {workdir, "nix-prepend-path", "push", "", platforms, secrets},
{workdir, "uses-docker-url", "push", "", platforms, secrets},
{workdir, "local-action-dockerfile", "push", "", platforms, secrets},
{workdir, "builtin-checkout", "push", "", platforms, secrets},
{workdir, "inputs-via-env-context", "push", "", platforms, secrets}, {workdir, "inputs-via-env-context", "push", "", platforms, secrets},
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets}, {workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
{workdir, "set-env-step-env-override", "push", "", platforms, secrets}, {workdir, "set-env-step-env-override", "push", "", platforms, secrets},
@@ -448,6 +452,8 @@ func TestRunEventHostEnvironment(t *testing.T) {
requireHostTools(t, "pwsh") requireHostTools(t, "pwsh")
case "nix-prepend-path": case "nix-prepend-path":
requireHostTools(t, "nix") requireHostTools(t, "nix")
case "uses-docker-url", "local-action-dockerfile":
requireDockerSharingFiles(t)
} }
t.Parallel() t.Parallel()
hostPlanSlots <- struct{}{} hostPlanSlots <- struct{}{}
+37 -39
View File
@@ -11,15 +11,14 @@ import (
"path" "path"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/internal/pkg/telemetry"
"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 {
@@ -67,28 +66,34 @@ func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunCo
} }
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor { func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) (err error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
rc := step.getRunContext() rc := step.getRunContext()
stepModel := step.getStepModel() stepModel := step.getStepModel()
ifExpression := step.getIfExpression(ctx, stage)
rc.CurrentStep = stepModel.ID
stepResult := &model.StepResult{ stepResult := &model.StepResult{
Outcome: model.StepStatusSuccess, Outcome: model.StepStatusSuccess,
Conclusion: model.StepStatusSuccess, Conclusion: model.StepStatusSuccess,
Outputs: make(map[string]string), Outputs: make(map[string]string),
} }
ctx, endStep := telemetry.StartStep(ctx, stepModel, stage.String())
defer func() { endStep(err, stepResult) }()
ifExpression := step.getIfExpression(ctx, stage)
rc.CurrentStep = stepModel.ID
if stage == stepStageMain { if stage == stepStageMain {
rc.StepResults[rc.CurrentStep] = stepResult rc.StepResults[rc.CurrentStep] = stepResult
} }
err := setupEnv(ctx, step) err = setupEnv(ctx, step)
var runStep bool var runStep bool
if err == nil { if err == nil {
runStep, err = isStepEnabled(ctx, ifExpression, step, stage) runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
} }
if err == nil && runStep {
err = setupInputs(ctx, step)
}
if err != nil { if err != nil {
stepResult.Conclusion = model.StepStatusFailure stepResult.Conclusion = model.StepStatusFailure
stepResult.Outcome = model.StepStatusFailure stepResult.Outcome = model.StepStatusFailure
@@ -240,51 +245,39 @@ func setupEnv(ctx context.Context, step step) error {
rc := step.getRunContext() rc := step.getRunContext()
mergeEnv(ctx, step) mergeEnv(ctx, step)
// merge step env last, since it should not be overwritten
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
var err error var err error
exprEval := rc.NewExpressionEvaluator(ctx) exprEval := rc.NewExpressionEvaluator(ctx)
for k, v := range *step.getEnv() { stepEnv := step.getStepModel().Environment()
if !strings.HasPrefix(k, "INPUT_") { for k, v := range stepEnv {
if (*step.getEnv())[k], err = exprEval.Interpolate(ctx, v); err != nil { if stepEnv[k], err = exprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err) return fmt.Errorf("unable to interpolate env %s: %w", k, err)
}
} }
} }
// after we have an evaluated step context, update the expressions evaluator with a new env context // merge step env last, since it should not be overwritten
// you can use step level env in the with property of a uses construct mergeIntoMap(step, step.getEnv(), stepEnv)
inputEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) }) return nil
for k, v := range *step.getEnv() { }
if strings.HasPrefix(k, "INPUT_") {
if (*step.getEnv())[k], err = inputEval().Interpolate(ctx, v); err != nil { // setupInputs evaluates `with` once the condition passed, as GitHub does, so every consumer reads the evaluated With.
return fmt.Errorf("unable to interpolate env %s: %w", k, err) func setupInputs(ctx context.Context, step step) error {
} stepModel := step.getStepModel()
} if stepModel.RawWith.Kind == 0 {
return nil
} }
if step.getStepModel().RawWith.Kind == yaml.ScalarNode { if err := model.DecodeEvaluated("with", stepModel.RawWith, step.getRunContext().NewStepExpressionEvaluator(ctx, step).shared(ctx).EvaluateYamlNode, &stepModel.With); err != nil {
decoded := &model.Step{} return err
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())
} }
mergeIntoMap(step, step.getEnv(), (&model.Step{With: stepModel.With}).GetEnv())
return nil return nil
} }
func mergeEnv(ctx context.Context, step step) { func mergeEnv(ctx context.Context, step step) {
env := step.getEnv() env := step.getEnv()
rc := step.getRunContext() rc := step.getRunContext()
job := rc.Run.Job()
c := job.Container() // container env is the image's baseline, which job env and $GITHUB_ENV override
if c != nil { mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
// container env is the image's baseline, which job env and $GITHUB_ENV override
mergeIntoMap(step, env, c.Env, rc.GetEnv())
} else {
mergeIntoMap(step, env, rc.GetEnv())
}
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env) rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
} }
@@ -299,7 +292,12 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage)
defaultStatusCheck = exprparser.DefaultStatusCheckSuccess defaultStatusCheck = exprparser.DefaultStatusCheckSuccess
} }
runStep, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, defaultStatusCheck) // success() and failure() in a composite's own main steps read the composite's result, other stages the job's
jobContext := rc.getJobContext()
if rc.Parent != nil && stage == stepStageMain && jobContext.Status != "cancelled" {
jobContext.Status = rc.ownStatus()
}
runStep, err := EvalBool(ctx, rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, jobContext), expr, defaultStatusCheck)
if err != nil { if err != nil {
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err) return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
} }
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/internal/pkg/action"
"gitea.com/gitea/runner/internal/pkg/action/checkout"
"gitea.dev/actionslib/pkg/model"
)
var builtinActions = map[string]func(context.Context, *action.Context) error{
"checkout": checkout.Main,
}
type stepActionBuiltin struct {
Step *model.Step
RunContext *RunContext
run func(context.Context, *action.Context) error
env map[string]string
}
func (sab *stepActionBuiltin) pre() common.Executor { return common.NewPipelineExecutor() }
func (sab *stepActionBuiltin) main() common.Executor {
sab.env = map[string]string{}
return runStepExecutor(sab, stepStageMain, func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
printRunActionHeader(ctx, sab.Step, sab.env, sab.RunContext)
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
defer rawLogger.Infof("::endgroup::")
rc := sab.RunContext
return sab.run(ctx, &action.Context{
Container: rc.JobContainer,
Github: rc.getGithubContext(ctx),
Inputs: sab.Step.With,
HostWorkdir: rc.Config.Workdir,
Workspace: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
BindWorkdir: rc.Config.BindWorkdir,
NoSkipCheckout: rc.Config.NoSkipCheckout,
UseGitIgnore: rc.Config.UseGitIgnore,
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
})
})
}
func (sab *stepActionBuiltin) post() common.Executor { return common.NewPipelineExecutor() }
func (sab *stepActionBuiltin) getRunContext() *RunContext { return sab.RunContext }
func (sab *stepActionBuiltin) getGithubContext(ctx context.Context) *model.GithubContext {
return sab.RunContext.getGithubContext(ctx)
}
func (sab *stepActionBuiltin) getStepModel() *model.Step { return sab.Step }
func (sab *stepActionBuiltin) getEnv() *map[string]string { return &sab.env }
func (sab *stepActionBuiltin) getIfExpression(_ context.Context, _ stepStage) string {
return sab.Step.If.Value
}
+3
View File
@@ -61,11 +61,14 @@ func (sal *stepActionLocal) main() common.Executor {
treader := tar.NewReader(tars) treader := tar.NewReader(tars)
header, err := treader.Next() header, err := treader.Next()
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
tars.Close()
return nil, nil, os.ErrNotExist return nil, nil, os.ErrNotExist
} else if err != nil { } else if err != nil {
tars.Close()
return nil, nil, err return nil, nil, err
} }
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink { if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
tars.Close()
spath, err = symlinkJoin(spath, header.Linkname, containerActionPath) spath, err = symlinkJoin(spath, header.Linkname, containerActionPath)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
+28 -65
View File
@@ -12,7 +12,6 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -58,13 +57,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
sar.Step.Uses = uses sar.Step.Uses = uses
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) { if sar.remoteAction, err = newRemoteAction(sar.Step.Uses, github, sar.RunContext.Config.GitHubInstance); err != nil {
sar.remoteAction = newSelfRepoAction(sar.Step.Uses, github) return err
} else {
sar.remoteAction = newRemoteAction(sar.Step.Uses)
}
if sar.remoteAction == nil {
return fmt.Errorf("expected format {org}/{repo}[/path]@ref or %s{path}. Actual '%s' Input string was not in a correct format", selfRepoPrefix, sar.Step.Uses)
} }
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout { if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
@@ -164,11 +158,7 @@ func (sar *stepActionRemote) main() common.Executor {
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace") common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
return nil return nil
} }
checkoutPath, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.With["path"]) copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), sar.Step.With["path"])
if err != nil {
return fmt.Errorf("unable to interpolate with.path: %w", err)
}
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath)
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx) return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
} }
@@ -324,61 +314,34 @@ func (ra *remoteAction) IsCheckout() bool {
return false return false
} }
// newSelfRepoAction resolves `$/{path}` against the enclosing composite action, falling back to the workflow's own repo and commit. // newRemoteAction resolves `self:` on instanceURL and `$/` in the enclosing composite action, else the workflow's repo and commit.
func newSelfRepoAction(action string, github *model.GithubContext) *remoteAction { func newRemoteAction(action string, github *model.GithubContext, instanceURL string) (*remoteAction, error) {
subPath := strings.TrimLeft(strings.TrimPrefix(action, selfRepoPrefix), "/") uses, err := model.ParseActionUses(action)
if subPath == "" || strings.Contains(subPath, "@") || path.Clean("/"+subPath) != "/"+subPath { // rooted, so a leading ".." is rejected too if err != nil {
return nil return nil, err
} }
repo, ref := github.ActionRepository, github.ActionRef ra := &remoteAction{URL: uses.URL, Org: uses.Owner, Repo: uses.Repo, Path: uses.Path, Ref: uses.Ref}
if repo == "" || ref == "" { switch uses.Kind {
repo, ref = github.Repository, github.Sha case model.ActionUsesInstance:
} if instanceURL == "" {
org, name, _ := strings.Cut(repo, "/") return nil, fmt.Errorf("unable to resolve %q without a Gitea instance", action)
if org == "" || name == "" || ref == "" { }
return nil ra.URL = instanceURL
} if !strings.Contains(instanceURL, "://") {
return &remoteAction{URL: github.ServerURL, Org: org, Repo: name, Path: subPath, Ref: ref} ra.URL = "https://" + instanceURL
} }
case model.ActionUsesSelfRepo:
func newRemoteAction(action string) *remoteAction { repo, ref := github.ActionRepository, github.ActionRef
// support http(s)://host/owner/repo@v3 if repo == "" || ref == "" {
for _, schema := range []string{"https://", "http://", "ssh://"} { repo, ref = github.Repository, github.Sha
if after, ok := strings.CutPrefix(action, schema); ok { }
splits := strings.SplitN(after, "/", 2) ra.Org, ra.Repo, _ = strings.Cut(repo, "/")
if len(splits) != 2 { ra.URL, ra.Ref = github.ServerURL, ref
return nil if ra.Org == "" || ra.Repo == "" || ra.Ref == "" {
} return nil, fmt.Errorf("unable to resolve %q without a repository and commit", action)
ret := parseAction(splits[1])
if ret == nil {
return nil
}
ret.URL = schema + splits[0]
return ret
} }
} }
return ra, nil
return parseAction(action)
}
func parseAction(action string) *remoteAction {
// GitHub's document[^] describes:
// > We strongly recommend that you include the version of
// > the action you are using by specifying a Git ref, SHA, or Docker tag number.
// Actually, the workflow stops if there is the uses directive that hasn't @ref.
// [^]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`)
matches := r.FindStringSubmatch(action)
if len(matches) < 7 || matches[6] == "" {
return nil
}
return &remoteAction{
Org: matches[1],
Repo: matches[2],
Path: matches[4],
Ref: matches[6],
URL: "",
}
} }
func safeFilename(s string) string { func safeFilename(s string) string {
+30 -9
View File
@@ -216,7 +216,7 @@ func TestStepActionRemote(t *testing.T) {
sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil) sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
} }
if tt.mocks.run { if tt.mocks.run {
sarm.On("runAction", sar, actionDirSuffix(sar.Step.UsesHash()), newRemoteAction(sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError }) sarm.On("runAction", sar, actionDirSuffix(sar.Step.UsesHash()), mustNewRemoteAction(t, sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError })
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor) cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor)
@@ -249,7 +249,7 @@ func TestStepActionRemote(t *testing.T) {
RunContext: &RunContext{Config: &Config{ActionCacheDir: t.TempDir()}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, JobContainer: &jobContainerMock{}}, RunContext: &RunContext{Config: &Config{ActionCacheDir: t.TempDir()}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, JobContainer: &jobContainerMock{}},
action: &model.Action{Inputs: map[string]model.Input{"shared": {}}}, action: &model.Action{Inputs: map[string]model.Input{"shared": {}}},
env: map[string]string{}, env: map[string]string{},
remoteAction: newRemoteAction("org/composite@v1"), remoteAction: mustNewRemoteAction(t, "org/composite@v1"),
} }
for _, value := range []string{"first", "second"} { for _, value := range []string{"first", "second"} {
@@ -464,7 +464,7 @@ func TestStepActionRemotePost(t *testing.T) {
Step: tt.stepModel, Step: tt.stepModel,
action: tt.actionModel, action: tt.actionModel,
// post only ever runs after prepareActionExecutor resolved the action // post only ever runs after prepareActionExecutor resolved the action
remoteAction: newRemoteAction(tt.stepModel.Uses), remoteAction: mustNewRemoteAction(t, tt.stepModel.Uses),
} }
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx) sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
@@ -611,10 +611,21 @@ func Test_newRemoteAction(t *testing.T) {
action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host
want: nil, want: nil,
}, },
{
action: "self:owner/repo/sub@v1",
want: &remoteAction{
URL: "https://gitea.example.com",
Org: "owner",
Repo: "repo",
Path: "sub",
Ref: "v1",
},
wantCloneURL: "https://gitea.example.com/owner/repo",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) { t.Run(tt.action, func(t *testing.T) {
got := newRemoteAction(tt.action) got, _ := newRemoteAction(tt.action, nil, "gitea.example.com")
assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action) assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action)
cloneURL := "" cloneURL := ""
if got != nil { if got != nil {
@@ -623,9 +634,18 @@ func Test_newRemoteAction(t *testing.T) {
assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action) assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action)
}) })
} }
_, err := newRemoteAction("self:owner/repo@v1", nil, "")
require.ErrorContains(t, err, "without a Gitea instance")
} }
func Test_newSelfRepoAction(t *testing.T) { func mustNewRemoteAction(t *testing.T, uses string) *remoteAction {
t.Helper()
action, err := newRemoteAction(uses, nil, "")
require.NoError(t, err)
return action
}
func Test_newRemoteActionSelfRepo(t *testing.T) {
workflow := &model.GithubContext{ workflow := &model.GithubContext{
ServerURL: "https://gitea.example.com", ServerURL: "https://gitea.example.com",
Repository: "owner/workflow-repo", Repository: "owner/workflow-repo",
@@ -674,7 +694,8 @@ func Test_newSelfRepoAction(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, newSelfRepoAction(tt.action, tt.github)) got, _ := newRemoteAction(tt.action, tt.github, "")
assert.Equal(t, tt.want, got)
}) })
} }
} }
@@ -704,7 +725,7 @@ func Test_remoteActionReference(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.uses, func(t *testing.T) { t.Run(tt.uses, func(t *testing.T) {
assert.Equal(t, tt.want, newRemoteAction(tt.uses).Reference()) assert.Equal(t, tt.want, mustNewRemoteAction(t, tt.uses).Reference())
}) })
} }
} }
@@ -761,7 +782,7 @@ func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
func TestStepActionRemoteActionDownloadInfo(t *testing.T) { func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
t.Run("reports the action and its resolved commit", func(t *testing.T) { t.Run("reports the action and its resolved commit", func(t *testing.T) {
sar := &stepActionRemote{ sar := &stepActionRemote{
remoteAction: newRemoteAction("actions/checkout@v7"), remoteAction: mustNewRemoteAction(t, "actions/checkout@v7"),
action: &model.Action{}, action: &model.Action{},
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
} }
@@ -775,7 +796,7 @@ func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
t.Run("reports nothing when no action was downloaded", func(t *testing.T) { t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
// The local checkout of the workflow's own repository resolves no action. // The local checkout of the workflow's own repository resolves no action.
sar := &stepActionRemote{remoteAction: newRemoteAction("actions/checkout@v7")} sar := &stepActionRemote{remoteAction: mustNewRemoteAction(t, "actions/checkout@v7")}
_, _, ok := sar.actionDownloadInfo() _, _, ok := sar.actionDownloadInfo()
+6 -16
View File
@@ -6,7 +6,6 @@ package runner
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -57,30 +56,21 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
step := sd.Step step := sd.Step
return func(ctx context.Context) error { return func(ctx context.Context) error {
image := strings.TrimPrefix(step.Uses, "docker://") if rc.kubernetes {
eval := rc.NewExpressionEvaluator(ctx) return errKubernetesDocker
args, err := eval.Interpolate(ctx, step.With["args"])
if err != nil {
return fmt.Errorf("unable to interpolate with.args: %w", err)
} }
cmd, err := shellquote.Split(args) image := strings.TrimPrefix(step.Uses, "docker://")
cmd, err := shellquote.Split(step.With["args"])
if err != nil { if err != nil {
return err return err
} }
var entrypoint []string var entrypoint []string
entry, err := eval.Interpolate(ctx, step.With["entrypoint"]) if entry := step.With["entrypoint"]; entry != "" {
if err != nil {
return fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
}
if entry != "" {
entrypoint = []string{entry} entrypoint = []string{entry}
} }
stepContainer, err := newStepContainer(ctx, sd, image, cmd, entrypoint, "") stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
if err != nil {
return err
}
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
stepContainer.Pull(rc.Config.ForcePull), stepContainer.Pull(rc.Config.ForcePull),
+20 -4
View File
@@ -8,6 +8,7 @@ import (
"bytes" "bytes"
"context" "context"
"io" "io"
"slices"
"strings" "strings"
"testing" "testing"
@@ -145,28 +146,41 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
} }
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
_, _ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "") newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
assert.Equal(t, tc.allocPTY, captured.AllocatePTY) assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
}) })
} }
} }
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) { func TestStepDockerNewStepContainerNetworkModeAndPath(t *testing.T) {
inheritedPath := (&container.LinuxContainerEnvironmentExtensions{}).DefaultPathVariable()
cases := []struct { cases := []struct {
name string name string
platform string platform string
path string
expectDefault bool expectDefault bool
keepsPath bool
}{ }{
{ {
name: "docker mode attaches to job container network", name: "docker mode attaches to job container network",
platform: "node:14", platform: "node:14",
path: inheritedPath,
expectDefault: false, expectDefault: false,
keepsPath: true,
}, },
{ {
name: "host mode uses default network", name: "host mode uses default network and the image's PATH",
platform: "-self-hosted", platform: "-self-hosted",
path: inheritedPath,
expectDefault: true, expectDefault: true,
}, },
{
name: "host mode keeps a PATH that is not the runner's",
platform: "-self-hosted",
path: "/custom/bin",
expectDefault: true,
keepsPath: true,
},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -208,13 +222,15 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
ID: "1", ID: "1",
Uses: "docker://alpine:3.20", Uses: "docker://alpine:3.20",
}, },
env: map[string]string{"PATH": tc.path},
} }
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
require.NoError(t, sd.RunContext.resolvePlatformImage(ctx)) require.NoError(t, sd.RunContext.resolvePlatformImage(ctx))
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform) assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform)
_, _ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "") newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
assert.Equal(t, tc.keepsPath, slices.Contains(captured.Env, "PATH="+tc.path))
if tc.expectDefault { if tc.expectDefault {
assert.Equal(t, "default", captured.NetworkMode, assert.Equal(t, "default", captured.NetworkMode,
+15
View File
@@ -6,6 +6,9 @@ package runner
import ( import (
"fmt" "fmt"
"maps"
"slices"
"strings"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
) )
@@ -33,6 +36,18 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step,
runAction: runActionImpl, runAction: runActionImpl,
}, nil }, nil
case model.StepTypeUsesActionRemote: case model.StepTypeUsesActionRemote:
if name, ok := strings.CutPrefix(stepModel.Uses, "builtin:"); ok {
run, ok := builtinActions[name]
if !ok {
return nil, fmt.Errorf("unknown built-in action %q (known: %v) for job:%s step:%+v",
name, slices.Sorted(maps.Keys(builtinActions)), rc.Run, stepModel)
}
return &stepActionBuiltin{
Step: stepModel,
RunContext: rc,
run: run,
}, nil
}
return &stepActionRemote{ return &stepActionRemote{
Step: stepModel, Step: stepModel,
RunContext: rc, RunContext: rc,
+16 -9
View File
@@ -57,6 +57,16 @@ func TestStepFactoryNewStep(t *testing.T) {
return ok return ok
}, },
}, },
{
name: "StepBuiltinAction",
model: &model.Step{
Uses: "builtin:checkout",
},
check: func(s step) bool {
_, ok := s.(*stepActionBuiltin)
return ok
},
},
} }
for _, tt := range table { for _, tt := range table {
@@ -72,14 +82,11 @@ func TestStepFactoryNewStep(t *testing.T) {
} }
func TestStepFactoryInvalidStep(t *testing.T) { func TestStepFactoryInvalidStep(t *testing.T) {
model := &model.Step{ for _, stepModel := range []*model.Step{
Uses: "remote/action@v1", {Uses: "remote/action@v1", Run: "cmd"},
Run: "cmd", {Uses: "builtin:unknown"},
} {
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{})
assert.Error(t, err, stepModel.Uses)
} }
sf := &stepFactoryImpl{}
_, err := sf.newStep(model, &RunContext{})
assert.Error(t, err)
} }
+53 -13
View File
@@ -13,13 +13,19 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/internal/pkg/telemetry"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
runnerv1 "gitea.dev/actionslib/runner/v1"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test" logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
tracenoop "go.opentelemetry.io/otel/trace/noop"
yaml "go.yaml.in/yaml/v4" yaml "go.yaml.in/yaml/v4"
) )
@@ -149,11 +155,8 @@ func TestSetupEnv(t *testing.T) {
}, },
JobContainer: cm, JobContainer: cm,
} }
step := &model.Step{ step := &model.Step{}
With: map[string]string{ require.NoError(t, step.RawWith.Encode(map[string]string{"STEP_WITH": "with-value", "ID": "${{ fromJSON('1234567') }}"}))
"STEP_WITH": "with-value",
},
}
env := map[string]string{} env := map[string]string{}
sm.On("getRunContext").Return(rc) sm.On("getRunContext").Return(rc)
@@ -162,6 +165,7 @@ func TestSetupEnv(t *testing.T) {
sm.On("getEnv").Return(&env) sm.On("getEnv").Return(&env)
require.NoError(t, setupEnv(context.Background(), sm)) require.NoError(t, setupEnv(context.Background(), sm))
require.NoError(t, setupInputs(context.Background(), sm))
// These are commit or system specific // These are commit or system specific
delete(env, "GITHUB_REF") delete(env, "GITHUB_REF")
@@ -198,6 +202,7 @@ func TestSetupEnv(t *testing.T) {
"GITHUB_RUN_NUMBER": "1", "GITHUB_RUN_NUMBER": "1",
"GITHUB_SERVER_URL": "https://", "GITHUB_SERVER_URL": "https://",
"GITHUB_WORKFLOW": "", "GITHUB_WORKFLOW": "",
"INPUT_ID": "1234567",
"INPUT_STEP_WITH": "with-value", "INPUT_STEP_WITH": "with-value",
"RC_KEY": "rcvalue", "RC_KEY": "rcvalue",
"RUNNER_ENVIRONMENT": "self-hosted", "RUNNER_ENVIRONMENT": "self-hosted",
@@ -214,7 +219,13 @@ env: ${{ fromJSON(matrix.env) }}
jobs: jobs:
test: test:
env: env:
PRIORITY: job PRIORITY: ${{ matrix.priority }}
defaults:
run: ${{ fromJSON(matrix.defaults) }}
container:
image: node:20
env:
CONTAINER: ${{ env.PRIORITY }}
steps: steps:
- uses: ./act - uses: ./act
env: env:
@@ -228,16 +239,22 @@ jobs:
for _, value := range []string{"first", "second"} { for _, value := range []string{"first", "second"} {
t.Run(value, func(t *testing.T) { t.Run(value, func(t *testing.T) {
t.Parallel() t.Parallel()
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, map[string]any{"env": `{"WORKFLOW":"` + value + `","PRIORITY":"workflow"}`}) rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, map[string]any{"env": `{"WORKFLOW":"` + value + `${{ github.job }}","PRIORITY":"workflow"}`, "priority": "${{ github.job }}", "defaults": `{"shell":"` + value + `"}`})
require.NoError(t, err) require.NoError(t, err)
rc.workflowCallInputs = map[string]any{"args": `{"name":"` + value + `","fetch-depth":2}`} rc.workflowCallInputs = map[string]any{"args": `{"name":"` + value + `${{ github.job }}","fetch-depth":1234567}`}
enabled, err := rc.isEnabled(t.Context())
require.NoError(t, err)
require.True(t, enabled)
require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc)) require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc))
assert.Equal(t, model.RunDefaults{Shell: value}, rc.jobRunDefaults)
step := &stepRun{RunContext: rc, Step: job.Steps[0].Clone(), env: map[string]string{}} step := &stepRun{RunContext: rc, Step: job.Steps[0].Clone(), env: map[string]string{}}
require.NoError(t, setupEnv(t.Context(), step)) 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()) require.NoError(t, setupInputs(t.Context(), step))
assert.Equal(t, value, step.env["INPUT_NAME"]) assert.Equal(t, map[string]string{"ARGS": `${{ inputs.args }}`, "INPUT_NAME": value + "${{ github.job }}", "INPUT_FETCH-DEPTH": "1234567"}, step.Step.GetEnv())
assert.Equal(t, value, step.env["WORKFLOW"]) assert.Equal(t, value+"${{ github.job }}", step.env["INPUT_NAME"])
assert.Equal(t, "job", step.env["PRIORITY"]) assert.Equal(t, value+"${{ github.job }}", step.env["WORKFLOW"])
assert.Equal(t, "${{ github.job }}", step.env["PRIORITY"])
assert.Equal(t, "${{ github.job }}", step.env["CONTAINER"])
assert.Equal(t, rawEnv, workflow.RawEnv) assert.Equal(t, rawEnv, workflow.RawEnv)
assert.Equal(t, rawWith, job.Steps[0].RawWith) assert.Equal(t, rawWith, job.Steps[0].RawWith)
assert.Nil(t, workflow.Env) assert.Nil(t, workflow.Env)
@@ -253,7 +270,7 @@ jobs:
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil) rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
require.NoError(t, err) require.NoError(t, err)
rc.workflowCallInputs = map[string]any{"args": "${{ inputs.unresolved }}"} 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:") require.ErrorContains(t, setupInputs(t.Context(), &stepRun{RunContext: rc, Step: workflow.Jobs["test"].Steps[0].Clone(), env: map[string]string{}}), "with:")
}) })
} }
@@ -536,6 +553,29 @@ func TestRunStepExecutorParity(t *testing.T) {
assert.Equal(t, "child", childRC.IntraActionState[child.Step.ID]["saved"]) assert.Equal(t, "child", childRC.IntraActionState[child.Step.ID]["saved"])
}) })
t.Run("stages and composite steps are traced under Gitea's step names", func(t *testing.T) {
spans := tracetest.NewSpanRecorder()
telemetry.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans)))
t.Cleanup(func() { telemetry.SetTracerProvider(tracenoop.NewTracerProvider()) })
ctx, _ := telemetry.StartJob(t.Context(), &runnerv1.Task{Id: 7})
outer := newStep(t, &model.Step{ID: "outer", Uses: "actions/cache@v4", Number: 3})
child := newStep(t, &model.Step{ID: "child", Name: "Restore", Number: 1})
skipped := newStep(t, &model.Step{ID: "skipped", Name: "Deploy", Number: 4, If: yaml.Node{Value: "false"}})
broken := newStep(t, &model.Step{ID: "broken", Name: "Broken", Number: 5, If: yaml.Node{Value: badExpression}})
require.NoError(t, runStepExecutor(outer, stepStagePost, runStepExecutor(child, stepStageMain, noopExecutor))(ctx))
require.NoError(t, runStepExecutor(skipped, stepStageMain, noopExecutor)(ctx))
require.Error(t, runStepExecutor(broken, stepStageMain, noopExecutor)(ctx))
ended := spans.Ended()
require.Len(t, ended, 4)
assert.Equal(t, []string{"Restore", "Post Run actions/cache@v4", "Deploy", "Broken"}, []string{ended[0].Name(), ended[1].Name(), ended[2].Name(), ended[3].Name()})
assert.Equal(t, ended[1].SpanContext().SpanID(), ended[0].Parent().SpanID())
assert.Contains(t, ended[0].Attributes(), semconv.CICDPipelineTaskRunID("7.3.post.1"))
assert.Contains(t, ended[2].Attributes(), semconv.CICDPipelineTaskRunResultSkip)
assert.Contains(t, ended[3].Attributes(), semconv.CICDPipelineTaskRunResultFailure)
})
t.Run("timeout must be positive", func(t *testing.T) { t.Run("timeout must be positive", func(t *testing.T) {
exprEval := createRunContext(t).NewExpressionEvaluator(context.Background()) exprEval := createRunContext(t).NewExpressionEvaluator(context.Background())
for timeout, wantDeadline := range map[string]bool{"-1": false, "0": false, "1": true} { for timeout, wantDeadline := range map[string]bool{"-1": false, "0": false, "1": true} {
+10
View File
@@ -0,0 +1,10 @@
name: builtin-checkout
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: builtin:checkout
with:
path: ${{ 'sub' }}
- run: test -f sub/builtin-checkout/push.yml
+5 -2
View File
@@ -5,6 +5,7 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- run: printf 'workspace=from-host-step\n' > workspace-marker
- uses: docker://node:24-bookworm-slim - uses: docker://node:24-bookworm-slim
with: with:
somekey: somevalue somekey: somevalue
@@ -16,8 +17,10 @@ jobs:
with: with:
entrypoint: /bin/sh entrypoint: /bin/sh
args: -c -- uname -a args: -c -- uname -a
- uses: docker://node:24-bookworm-slim - id: workspace
uses: docker://node:24-bookworm-slim
with: with:
entrypoint: /bin/sh entrypoint: /bin/sh
args: -c 'uname -a' args: -c 'uname -a && cat "$GITHUB_WORKSPACE/workspace-marker" >> "$GITHUB_OUTPUT"'
- run: test '${{ steps.workspace.outputs.workspace }}' = 'from-host-step'
+21
View File
@@ -0,0 +1,21 @@
# Built-in actions
Built-in actions ship with the runner, so a job needs no action download and no Node in its image.
```yaml
- uses: builtin:checkout
```
## checkout
Works like `actions/checkout` with these inputs. Any other input fails the step.
| Input | Default |
| --- | --- |
| `repository` | `github.repository` |
| `ref` | the triggering commit, or the default branch of another repository |
| `token` | `github.token` |
| `path` | workspace root |
| `fetch-depth` | `1`, `0` for full history |
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` is set.
+58
View File
@@ -0,0 +1,58 @@
# Jobs as Kubernetes pods
A `kubernetes` label runs each job as a pod, without a Docker daemon:
```text
ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest
```
The pod has a `job` container from the label's image or the job's `container.image`, plus one container per service.
## Setup
In a cluster, the runner uses its pod's service account and namespace. [`examples/kubernetes/kubernetes-pods.yaml`](../examples/kubernetes/kubernetes-pods.yaml) deploys a runner with these permissions:
| Resource | Verbs |
| --- | --- |
| `pods` | `create`, `get`, `list`, `delete` |
| `pods/exec` | `create`, `get` |
| `pods/log` | `get` |
| `secrets` | `create`, `delete` |
Outside a cluster, it uses the current context of the first file in `$KUBECONFIG`, or of `~/.kube/config`, which must authenticate with a token, token file or client certificate. The cluster must run Kubernetes 1.30 or later, and job images need `sh`, `tar`, `tail` and `env`.
## Configuration
```yaml
kubernetes:
kubeconfig: "" # instead of the service account
namespace: "" # defaults to the service account's, the kubeconfig context's, or default
pod_template: # merged under each job pod, a container named job merges into the job container, one named $services into each service container
spec:
containers:
- name: job
resources:
limits:
memory: 4Gi
- name: $services
resources:
limits:
memory: 1Gi
pod_templates: # merged over pod_template for jobs whose runs-on has the label they are keyed by
gpu:
spec:
nodeSelector:
nvidia.com/gpu.present: "true"
```
A job with `runs-on: [linux, gpu]` gets the `linux` and then the `gpu` entry of `pod_templates` merged over `pod_template`, while its image comes from the first of its labels the runner has. A template volume named `workspace` or `act` replaces the `emptyDir` the runner mounts at the workspace or at `/var/run/act`, for example to limit its size. Images are pulled by the cluster's `imagePullPolicy` default, a template's, or always with `container.force_pull`.
Job pods mount no service account token unless the template sets `automountServiceAccountToken: true`. Job pods and their Secrets are labeled `app.kubernetes.io/managed-by: gitea-runner` and `com.gitea.runner.uuid: <runner UUID>`. The idle cleanup removes those the runner did not get to remove, once they are older than `runner.workdir_cleanup_age`.
## Services
Services share the pod's network, so a step reaches one by its id or `localhost`, and two services cannot listen on the same port. An id is lowercased and has characters other than letters, digits and `-` replaced by `-`, so the service `my_db` is reached as `my-db`. Pass secrets to services through `env`, as `command` is visible in the pod spec. Of `options`, only `--health-*` is supported. They become a startup probe with Docker's defaults, which stops a service that fails its retries, so the job fails as on Docker.
## Unsupported
Docker actions, `docker://` steps, and the `options`, `volumes` and `credentials` of the job container fail the job, as do `volumes` and `credentials` of services. Run such workflows on a [Docker in Docker](../examples/kubernetes/README.md) runner. Pull private images with `imagePullSecrets` in `pod_template`.
+52
View File
@@ -0,0 +1,52 @@
# OpenTelemetry
The runner can send a trace of every job it runs, and its metrics, to an OpenTelemetry collector or any backend that accepts OTLP. Each job becomes a trace with a span per step, so you can see where CI time goes and which steps fail. Export stays off until you set an endpoint.
## Setup
Set the endpoint in the runner's environment, for example in its systemd unit, with `docker run -e` or in the Kubernetes pod spec:
```sh
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
```
| Variable | Purpose |
| --- | --- |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector base URL, traces go to `/v1/traces` and metrics to `/v1/metrics` under it |
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Full URL per signal, takes precedence over the base URL |
| `OTEL_EXPORTER_OTLP_HEADERS` | Request headers such as an API key, as comma-separated `key=value` pairs with URL-encoded values |
| `OTEL_EXPORTER_OTLP_TIMEOUT` | Export timeout in milliseconds, default 10000, 0 for none |
| `OTEL_EXPORTER_OTLP_COMPRESSION` | `gzip` or `none`, default `none` |
| `OTEL_EXPORTER_OTLP_CERTIFICATE` | PEM file of the CA to trust instead of the system ones |
| `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE`, `OTEL_EXPORTER_OTLP_CLIENT_KEY` | PEM files of the client certificate and key for mTLS |
| `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES` | Resource attributes, the service name defaults to `gitea-runner` |
| `OTEL_TRACES_SAMPLER`, `OTEL_BSP_*` | Sampling and batching, see the [SDK variables](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) |
| `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_METRIC_EXPORT_TIMEOUT` | Metrics export interval and timeout in milliseconds, default 60000 and 30000, a timeout of 0 for none |
| `OTEL_SDK_DISABLED=true` | Turn all export off |
| `OTEL_TRACES_EXPORTER=none`, `OTEL_METRICS_EXPORTER=none` | Turn traces or metrics off |
Except for the endpoint, each `OTEL_EXPORTER_OTLP_*` variable has `TRACES` and `METRICS` variants such as `OTEL_EXPORTER_OTLP_METRICS_HEADERS`, which take precedence for that signal. Only the `otlp` exporter over the `http/protobuf` protocol is supported.
## What is sent
- A span per job, named `RUN {workflow} {job}`, with the repository, ref, commit, run URL, run number, attempt and result.
- A span per step, named as in Gitea's step list, including `Pre` and `Post` stages and the steps of composite actions, with the step's result.
- The runner's name, UUID and version.
Logs, step output and secrets are never sent. Step names are taken from the workflow file as written, without expanding `${{ }}` expressions.
## Tracing inside jobs
Jobs receive `TRACEPARENT`, so tools in a job that use OpenTelemetry can add their spans to the job's trace. To let them export, set their `OTEL_*` variables in `runner.envs`, see [Security](#security).
## Metrics
The metrics of the Prometheus endpoint are also exported, under the same names and whether or not `metrics.enabled` is set. They are sent every export interval and once more on shutdown. If your collector only accepts traces, set `OTEL_METRICS_EXPORTER=none`.
## Security
- OTLP has no authentication of its own. Send to a collector on the runner's host that holds the backend credentials, as the [OpenTelemetry security guide](https://opentelemetry.io/docs/security/config-best-practices/) recommends, or use `https` with a token in `OTEL_EXPORTER_OTLP_HEADERS` or a client certificate. Over plain `http`, headers travel unencrypted.
- TLS certificates are verified against the system CA bundle, or only against `OTEL_EXPORTER_OTLP_CERTIFICATE` when set. Redirects are not followed, so headers only reach the configured endpoint.
- Jobs in containers never see the runner's `OTEL_*` variables. Jobs on the host inherit the runner's whole environment, headers included, and can read its files anyway since they run as the same user.
- Every workflow can read `runner.envs`, so never put credentials there. Point jobs at a collector that holds them instead, keeping in mind that any workflow can then send data through it.
- Traces carry the repository and step names of every job, private repositories included, so give the collector the same access controls as the runner's logs.
+12 -6
View File
@@ -51,23 +51,29 @@ func testRunCancellation(t *testing.T) {
func waitForRunningJobLog(t *testing.T, api *GiteaAPI, repo string, runID int64, substr string) { func waitForRunningJobLog(t *testing.T, api *GiteaAPI, repo string, runID int64, substr string) {
t.Helper() t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute) // The budget covers scheduling and the container start, which a loaded runner makes slow.
ctx, cancel := context.WithTimeout(t.Context(), runTimeout)
defer cancel() defer cancel()
status := "none"
for { for {
jobs, err := api.Jobs(ctx, repo, runID) jobs, err := api.Jobs(ctx, repo, runID)
if err != nil { if err != nil {
t.Fatalf("list jobs: %v", err) t.Fatalf("list jobs: %v", err)
} }
if len(jobs) > 0 && jobs[0].Status == "in_progress" { if len(jobs) > 0 {
logs, err := api.JobLogs(ctx, repo, jobs[0].ID) status = jobs[0].Status
if err == nil && commandRow(logs, substr) == substr { if status == "in_progress" {
return logs, err := api.JobLogs(ctx, repo, jobs[0].ID)
if err == nil && commandRow(logs, substr) == substr {
return
}
} }
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
t.Fatalf("running job for run %d never logged %q", runID, substr) dumpRunLogs(t, api, repo, runID)
t.Fatalf("job of run %d is %q and never logged %q", runID, status, substr)
case <-time.After(pollInterval): case <-time.After(pollInterval):
} }
} }
+2
View File
@@ -31,6 +31,7 @@ func TestCompatibility(t *testing.T) {
t.Run("cancellation_and_log_streaming", testRunCancellation) t.Run("cancellation_and_log_streaming", testRunCancellation)
t.Run("dispatch", testWorkflowDispatch) t.Run("dispatch", testWorkflowDispatch)
t.Run("ephemeral", testEphemeralRunner) t.Run("ephemeral", testEphemeralRunner)
t.Run("kubernetes", testKubernetes)
} }
func testPayloads(t *testing.T) { func testPayloads(t *testing.T) {
@@ -69,6 +70,7 @@ func testPayloads(t *testing.T) {
"cell-a-2", "cell-a-2",
"cell-b-1", "cell-b-1",
"cell-b-2", "cell-b-2",
"imageless env=imageless-env-value host=imageless-host",
} { } {
if !strings.Contains(logs, want) { if !strings.Contains(logs, want) {
t.Errorf("stored logs are missing %q", want) t.Errorf("stored logs are missing %q", want)
+1 -1
View File
@@ -22,7 +22,7 @@ func testEphemeralRunner(t *testing.T) {
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("ephemeral runner was not deleted") t.Fatal("ephemeral runner was not deleted")
} }
if !poller.Unregistered() { if !poller.RegistrationRejected() {
t.Fatal("ephemeral runner stopped without being deleted") t.Fatal("ephemeral runner stopped without being deleted")
} }
} }
+17 -5
View File
@@ -23,6 +23,7 @@ import (
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
"github.com/moby/moby/api/pkg/stdcopy"
apicontainer "github.com/moby/moby/api/types/container" apicontainer "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"
@@ -306,9 +307,14 @@ func randomToken(n int) string {
} }
func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) { func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) {
created, err := f.cli.ExecCreate(ctx, f.id, mobyclient.ExecCreateOptions{ return containerExec(ctx, f.cli, f.id, "git", nil, cmd...) // gitea refuses root
}
func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user string, stdin io.Reader, cmd ...string) (string, error) {
created, err := cli.ExecCreate(ctx, id, mobyclient.ExecCreateOptions{
Cmd: cmd, Cmd: cmd,
User: "git", // gitea refuses root User: user,
AttachStdin: stdin != nil,
AttachStdout: true, AttachStdout: true,
AttachStderr: true, AttachStderr: true,
}) })
@@ -316,18 +322,24 @@ func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) {
return "", err return "", err
} }
attached, err := f.cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{}) attached, err := cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{})
if err != nil { if err != nil {
return "", err return "", err
} }
defer attached.Close() defer attached.Close()
if stdin != nil {
go func() {
_, _ = io.Copy(attached.Conn, stdin)
_ = attached.CloseWrite()
}()
}
var out bytes.Buffer var out bytes.Buffer
if _, err := io.Copy(&out, attached.Reader); err != nil { if _, err := stdcopy.StdCopy(&out, &out, attached.Reader); err != nil {
return "", err return "", err
} }
inspected, err := f.cli.ExecInspect(ctx, created.ID, mobyclient.ExecInspectOptions{}) inspected, err := cli.ExecInspect(ctx, created.ID, mobyclient.ExecInspectOptions{})
if err != nil { if err != nil {
return "", err return "", err
} }
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import (
"bytes"
"context"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/moby/moby/api/pkg/stdcopy"
apicontainer "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
mobyclient "github.com/moby/moby/client"
)
func testKubernetes(t *testing.T) {
t.Parallel()
ctx := t.Context()
k3sID, kubernetes := startK3s(t)
images := preloadImages(t, k3sID, os.Getenv("E2E_JOB_IMAGE"), os.Getenv("SERVICE_IMAGE"))
api, repo := newScenario(t)
if err := api.CreateVariable(ctx, repo, "E2E_SERVICE_IMAGE", images[1]); err != nil {
t.Fatalf("create service image variable: %v", err)
}
startRunner(t, repo, "e2e-kubernetes", runnerOptions{image: images[0], kubernetes: kubernetes})
pushWorkflow(t, api, repo, "kubernetes.yml")
wfRun := waitForRun(t, api, repo)
requireSuccess(t, api, repo, wfRun.ID)
if out, err := containerExec(ctx, fixture.cli, k3sID, "", nil,
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
); err != nil {
t.Errorf("job pods or secrets left behind: %v: %s", err, out)
}
}
func startK3s(t *testing.T) (string, *config.Kubernetes) {
t.Helper()
ctx := t.Context()
cli := fixture.cli
name := fmt.Sprintf("gitea-runner-e2e-k3s-%d", time.Now().UnixNano())
apiPort := network.MustParsePort("6443/tcp")
server := "https://" + name + ":6443"
hostConfig := &apicontainer.HostConfig{Privileged: true}
var netConfig *network.NetworkingConfig
if fixture.network != "" {
netConfig = &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{fixture.network: {}}}
} else {
loopback := netip.MustParseAddr("127.0.0.1")
port, err := freeHostPort(loopback.String())
if err != nil {
t.Fatalf("find a free host port: %v", err)
}
server = "https://" + net.JoinHostPort(loopback.String(), strconv.Itoa(port))
hostConfig.PortBindings = network.PortMap{apiPort: []network.PortBinding{{HostIP: loopback, HostPort: strconv.Itoa(port)}}}
}
created, err := cli.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
Config: &apicontainer.Config{
Image: os.Getenv("E2E_K3S_IMAGE"),
Cmd: []string{"server", "--disable=traefik,metrics-server,servicelb,local-storage,coredns", "--tls-san=" + name, "--kubelet-arg=fail-cgroupv1=false"},
ExposedPorts: network.PortSet{apiPort: struct{}{}},
},
HostConfig: hostConfig,
NetworkingConfig: netConfig,
Name: name,
})
if err != nil {
t.Fatalf("create k3s container: %v", err)
}
t.Cleanup(func() {
_, _ = cli.ContainerRemove(context.Background(), created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
})
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
t.Fatalf("start k3s container: %v", err)
}
waitCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
for {
_, err := containerExec(waitCtx, cli, created.ID, "", nil, "kubectl", "get", "serviceaccount", "default")
if err == nil {
break
}
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
if inspectErr != nil || !inspected.Container.State.Running || waitCtx.Err() != nil {
var logs bytes.Buffer
if reader, logsErr := cli.ContainerLogs(ctx, created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
_, _ = stdcopy.StdCopy(&logs, &logs, reader)
_ = reader.Close()
}
t.Fatalf("k3s did not start: %v, state %+v %v, last log lines:\n%s", err, inspected.Container.State, inspectErr, logs.String())
}
select {
case <-waitCtx.Done():
case <-time.After(pollInterval):
}
}
kubeconfig, err := containerExec(ctx, cli, created.ID, "", nil, "cat", "/etc/rancher/k3s/k3s.yaml")
if err != nil {
t.Fatalf("read kubeconfig: %v", err)
}
path := filepath.Join(t.TempDir(), "kubeconfig")
if err := os.WriteFile(path, []byte(strings.ReplaceAll(kubeconfig, "https://127.0.0.1:6443", server)), 0o600); err != nil {
t.Fatalf("write kubeconfig: %v", err)
}
kubernetes := &config.Kubernetes{Kubeconfig: path, Namespace: "default"}
if fixture.network != "" {
gitea, err := cli.ContainerInspect(ctx, fixture.id, mobyclient.ContainerInspectOptions{})
if err != nil {
t.Fatalf("inspect gitea container: %v", err)
}
giteaURL, err := url.Parse(fixture.baseURL)
if err != nil {
t.Fatalf("parse gitea url: %v", err)
}
kubernetes.PodTemplate = map[string]any{"spec": map[string]any{"hostAliases": []any{map[string]any{
"ip": gitea.Container.NetworkSettings.Networks[fixture.network].IPAddress.String(),
"hostnames": []any{giteaURL.Hostname()},
}}}}
}
return created.ID, kubernetes
}
func preloadImages(t *testing.T, k3sID string, images ...string) []string {
t.Helper()
ctx := t.Context()
var aliases []string
for index, image := range images {
alias := fmt.Sprintf("gitea-runner-e2e/preload-%d:%d", index, time.Now().UnixNano())
if _, err := fixture.cli.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
t.Fatalf("tag %s: %v", image, err)
}
t.Cleanup(func() { _, _ = fixture.cli.ImageRemove(context.Background(), alias, mobyclient.ImageRemoveOptions{}) })
aliases = append(aliases, alias)
}
archive, err := fixture.cli.ImageSave(ctx, aliases)
if err != nil {
t.Fatalf("save %v: %v", images, err)
}
defer archive.Close()
if out, err := containerExec(ctx, fixture.cli, k3sID, "", archive, "ctr", "--namespace=k8s.io", "images", "import", "-"); err != nil {
t.Fatalf("import %v into k3s: %v: %s", images, err, out)
}
return aliases
}
+12 -5
View File
@@ -6,6 +6,7 @@
package e2e package e2e
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"os" "os"
@@ -24,10 +25,12 @@ import (
) )
type runnerOptions struct { type runnerOptions struct {
capacity int capacity int
ephemeral bool ephemeral bool
cacheV2 *bool cacheV2 *bool
cacheHost string cacheHost string
image string
kubernetes *config.Kubernetes
} }
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller { func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
@@ -61,7 +64,11 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
cfg.Container.Network = fixture.network cfg.Container.Network = fixture.network
} }
rawLabel := labelName + ":docker://" + os.Getenv("E2E_JOB_IMAGE") scheme := labels.SchemeDocker
if options.kubernetes != nil {
scheme, cfg.Kubernetes = labels.SchemeKubernetes, *options.kubernetes
}
rawLabel := labelName + ":" + scheme + "://" + cmp.Or(options.image, os.Getenv("E2E_JOB_IMAGE"))
label, err := labels.Parse(rawLabel) label, err := labels.Parse(rawLabel)
if err != nil { if err != nil {
t.Fatalf("parse label %q: %v", rawLabel, err) t.Fatalf("parse label %q: %v", rawLabel, err)
+1 -1
View File
@@ -6,4 +6,4 @@ jobs:
steps: steps:
- run: | - run: |
echo e2e-live-log-marker echo e2e-live-log-marker
timeout 2s tail -f /dev/null || true timeout 10s tail -f /dev/null || true
+20
View File
@@ -0,0 +1,20 @@
name: kubernetes
on: push
jobs:
pod:
runs-on: e2e-kubernetes
services:
web:
image: ${{ vars.E2E_SERVICE_IMAGE }}
env:
NGINX_ENTRYPOINT_QUIET_LOGS: 1
options: --health-cmd "wget -q -O /dev/null http://localhost/" --health-interval 1s
steps:
- uses: actions/checkout@v4
- run: |
test -f .gitea/workflows/kubernetes.yml
echo "FROM_ENV=exported" >> "$GITHUB_ENV"
- run: |
test "$FROM_ENV" = exported
test -n "$KUBERNETES_SERVICE_HOST" -a -n "${{ runner.arch }}"
curl -fsS -o /dev/null http://web/
+5
View File
@@ -3,6 +3,10 @@ on: push
jobs: jobs:
verify: verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
env:
IMAGELESS_ENV: imageless-env-value
options: --hostname imageless-host
services: services:
web: web:
image: ${{ vars.E2E_SERVICE_IMAGE }} image: ${{ vars.E2E_SERVICE_IMAGE }}
@@ -19,6 +23,7 @@ jobs:
echo '::notice::encoded-first%0Aencoded-second' echo '::notice::encoded-first%0Aencoded-second'
echo "the variable is ${{ vars.GREETING }}" echo "the variable is ${{ vars.GREETING }}"
echo "the secret is ${{ secrets.FOO }}" echo "the secret is ${{ secrets.FOO }}"
echo "imageless env=$IMAGELESS_ENV host=$(hostname)"
curl --connect-timeout 1 --max-time 2 --retry 30 --retry-delay 1 --retry-max-time 30 --retry-all-errors -fsS -o /dev/null http://web/ curl --connect-timeout 1 --max-time 2 --retry 30 --retry-delay 1 --retry-max-time 30 --retry-all-errors -fsS -o /dev/null http://web/
produce: produce:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+6 -5
View File
@@ -1,5 +1,7 @@
### Running `gitea-runner` using `docker-compose` ### Running `gitea-runner` using `docker-compose`
This uses the recommended Docker-in-Docker image, see [image flavours](../../README.md#image-flavours).
```yml ```yml
... ...
gitea: gitea:
@@ -19,8 +21,9 @@
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token> # - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
runner: runner:
image: gitea/runner image: gitea/runner:latest-dind
restart: always restart: always
privileged: true
depends_on: depends_on:
gitea: gitea:
# required so runner can attach to gitea, see "healthcheck" # required so runner can attach to gitea, see "healthcheck"
@@ -28,7 +31,6 @@
restart: true restart: true
volumes: volumes:
- ./data/runner:/data - ./data/runner:/data
- /var/run/docker.sock:/var/run/docker.sock
environment: environment:
- GITEA_INSTANCE_URL=<instance url> - GITEA_INSTANCE_URL=<instance url>
# When using Docker Secrets, it's also possible to use # When using Docker Secrets, it's also possible to use
@@ -38,10 +40,9 @@
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration token> - GITEA_RUNNER_REGISTRATION_TOKEN=<registration token>
``` ```
### Running `gitea-runner` using Docker-in-Docker (DIND) ### Running `gitea-runner` using rootless Docker-in-Docker
- `privileged` has to be set to `true` because in-container Docker daemon requires a lot of kernel capabilities and file system mounts like `procfs` and `sysfs` On AppArmor hosts such as Ubuntu and Debian, `security_opt` is needed for the daemon to start. Docker's `docker-ce-rootless-extras` package provides the `rootlesskit` profile.
- `security_opt` sets the `apparmor` profile to `rootlesskit` for hosts running AppArmor (e.g. Ubuntu, Debian), where the kernel might otherwise block user namespace changes that Docker daemon requires for startup. The `rootlesskit` profile is provided by the `docker-ce-rootless-extras` package and is present on hosts where Docker was installed via the official installer or distro packages
```yml ```yml
... ...
+9 -3
View File
@@ -1,8 +1,14 @@
### Run `gitea-runner` in a Docker Container ### Run `gitea-runner` in a Docker Container
The recommended Docker-in-Docker image runs its own Docker daemon for jobs, so they never get the host's Docker socket:
```sh ```sh
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/data:/data --name my_runner gitea/runner:nightly docker run --privileged \
-e GITEA_INSTANCE_URL=http://192.168.8.18:3000 \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> \
-v "$PWD/data:/data" \
-v runner-docker:/var/lib/docker \
--name my_runner gitea/runner:nightly-dind
``` ```
The `/data` directory inside the docker container contains the runner API keys after registration. The volumes keep the runner's registration and the job image cache. See [image flavours](../../README.md#image-flavours) for alternatives.
It must be persisted, otherwise the runner would try to register again, using the same, now defunct registration token.
+5 -22
View File
@@ -1,25 +1,8 @@
## Kubernetes Docker in Docker Deployment with `gitea-runner` ## Kubernetes deployments of `gitea-runner`
NOTE: Docker in Docker (dind) requires elevated privileges on Kubernetes. The current way to achieve this is to set the pod `SecurityContext` to `privileged`. Keep in mind that this is a potential security issue that has the potential for a malicious application to break out of the container context. Jobs can run on Kubernetes in two ways:
NOTE: `dind-docker.yaml` uses the native sidecar pattern (init container with `restartPolicy: Always`), which requires Kubernetes 1.29+ (or 1.28 with the `SidecarContainers` feature gate). - **Job pods**: each job runs as its own pod, without privileged containers and under the cluster's quotas and policies. Docker actions and `docker://` steps are not supported. Deploy with [`kubernetes-pods.yaml`](kubernetes-pods.yaml) and see [docs/kubernetes.md](../../docs/kubernetes.md).
- **Docker in Docker**: jobs run in a Docker daemon next to the runner. Every workflow feature works, but the daemon is privileged, so a job can break out to the node. Deploy with [`dind-docker.yaml`](dind-docker.yaml), [`statefulset-dind.yaml`](statefulset-dind.yaml) for several replicas, [`rootless-docker.yaml`](rootless-docker.yaml) for a rootless daemon, or the [Helm chart](https://gitea.com/gitea/helm-actions). The sidecars in the first two need Kubernetes 1.29 or later.
NOTE: A helm chart for `gitea-runner` also exists for easier deployments https://gitea.com/gitea/helm-actions All examples read the registration token from the `runner-secret` Secret and the Gitea URL from `GITEA_INSTANCE_URL`, and keep the registration in `/data`. They give a stopping pod three hours to finish its jobs, so keep `runner.shutdown_timeout` below `terminationGracePeriodSeconds`.
Each example persists **two** things, and it is worth knowing which is which:
- `/data` is the runner's working directory. It holds the `.runner` registration file and, optionally, the config file — so the runner re-attaches to the server instead of registering again.
- The Docker daemon's data root holds the images pulled for jobs (`/var/lib/docker` for the dind sidecar, `/home/rootless/.local/share/docker` for `dind-rootless`). It is *not* under `/data`. If you drop this volume, the examples still work, but the image cache is discarded whenever the pod is recreated and every job re-pulls its images.
- Kubernetes SIGKILLs a pod 30s after SIGTERM by default, long before a job finishes and reports its result, which leaves tasks the server can only reap as zombies. The manifests raise `terminationGracePeriodSeconds` to three hours, matching the systemd example and the `runner.timeout` job ceiling; set `runner.shutdown_timeout` below that so the runner drains jobs within the window rather than being killed mid-cleanup.
Files in this directory:
- [`dind-docker.yaml`](dind-docker.yaml)
How to create a Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
- [`rootless-docker.yaml`](rootless-docker.yaml)
How to create a rootless Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
- [`statefulset-dind.yaml`](statefulset-dind.yaml)
StatefulSet variant of the dind example. Each replica gets a stable identity and its own persistent volume via `volumeClaimTemplates`, so the runner keeps its `.runner` registration across restarts and reschedules instead of trying to register again.
+83
View File
@@ -0,0 +1,83 @@
apiVersion: v1
data:
token: << base64 encoded registration token >>
kind: Secret
metadata:
name: runner-secret
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: runner
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: runner
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["create", "get", "list", "delete"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create", "get"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: runner
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: runner
subjects:
- kind: ServiceAccount
name: runner
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: runner
spec:
serviceName: runner
selector:
matchLabels:
app: runner
template:
metadata:
labels:
app: runner
spec:
serviceAccountName: runner
terminationGracePeriodSeconds: 10800 # keep above runner.shutdown_timeout, see README
containers:
- name: runner
image: gitea/runner:nightly
env:
- name: GITEA_INSTANCE_URL
value: http://gitea-http.gitea.svc.cluster.local:3000
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:
name: runner-secret
key: token
- name: GITEA_RUNNER_LABELS
value: ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest
volumeMounts:
- name: runner-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: runner-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
+15 -7
View File
@@ -7,18 +7,20 @@ toolchain go1.27.1
require ( require (
connectrpc.com/connect v1.21.0 connectrpc.com/connect v1.21.0
dario.cat/mergo v1.0.2 dario.cat/mergo v1.0.2
gitea.dev/actionslib v1.0.0 gitea.dev/actionslib v1.2.1
github.com/avast/retry-go/v5 v5.0.0 github.com/avast/retry-go/v5 v5.0.0
github.com/bmatcuk/doublestar/v4 v4.10.2
github.com/coder/websocket v1.8.15
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.8.0+incompatible github.com/docker/cli v29.8.1+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
github.com/go-git/go-git/v5 v5.19.2 github.com/go-git/go-git/v5 v5.19.2
github.com/gobwas/glob v1.0.0
github.com/google/go-cmp v0.7.0 github.com/google/go-cmp v0.7.0
github.com/johannesboyne/gofakes3 v1.2.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
@@ -37,6 +39,11 @@ 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.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0
go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/trace v1.46.0
go.opentelemetry.io/proto/otlp v1.11.0
go.yaml.in/yaml/v4 v4.0.0-rc.6 go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/net v0.59.0 golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0 golang.org/x/sync v0.23.0
@@ -53,7 +60,6 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect
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/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.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
@@ -68,6 +74,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect
@@ -82,6 +89,7 @@ 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/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // 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
@@ -89,10 +97,10 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // 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/metric v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // 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.57.0 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/tools v0.49.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
) )
+62 -18
View File
@@ -4,8 +4,8 @@ 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 v1.0.0 h1:l0oFJP+P4Ds1rlCI5zk618dYkuBc2mU7Gz5wPeG0lZY= gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0=
gitea.dev/actionslib v1.0.0/go.mod h1:6O8YHkqVTKSR0LL2e5VhIDePYzGTZCbfmSVqJWEhk9g= gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
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=
@@ -19,14 +19,44 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k= github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k=
github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8= github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.10.2/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/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
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/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -44,8 +74,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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.1+incompatible h1:qYL1bCp6cRw2SB1xmLlIOPyV171dilw9W2Jew38vy9c=
github.com/docker/cli v29.8.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v29.8.1+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=
@@ -75,8 +105,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -89,6 +117,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/johannesboyne/gofakes3 v1.2.0 h1:I9VEzPWvvAUAGzDlhYFoZjF0AXMlkcEyZlmBwiI6Oms=
github.com/johannesboyne/gofakes3 v1.2.0/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
@@ -158,6 +188,8 @@ github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIj
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/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= 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/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=
@@ -167,6 +199,8 @@ github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBB
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -196,16 +230,22 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
@@ -242,12 +282,16 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= 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=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+3
View File
@@ -29,6 +29,9 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
} }
initLogging(cfg) initLogging(cfg)
if cfg.Cache.S3 != nil {
return errors.New("cache-server does not support cache.s3; set cache.s3 on the runners instead")
}
var ( var (
dir = cfg.Cache.Dir dir = cfg.Cache.Dir
+13
View File
@@ -27,6 +27,19 @@ func loadConfigCmd(configFile *string) *cobra.Command {
configCmd.AddCommand(loadGenerateConfigCmd("generate")) configCmd.AddCommand(loadGenerateConfigCmd("generate"))
configCmd.AddCommand(loadInitConfigCmd(configFile)) configCmd.AddCommand(loadInitConfigCmd(configFile))
configCmd.AddCommand(&cobra.Command{
Use: "runner-file",
Short: "Print the registration file path used by the runner",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.LoadDefault(*configFile)
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), cfg.Runner.File)
return nil
},
})
configCmd.AddCommand(&cobra.Command{ configCmd.AddCommand(&cobra.Command{
Use: "get <key>", Use: "get <key>",
+11
View File
@@ -97,3 +97,14 @@ func TestConfigCmdResolvesTheConfigFile(t *testing.T) {
assert.Contains(t, err.Error(), "--config") assert.Contains(t, err.Error(), "--config")
}) })
} }
func TestConfigCmdRunnerFileUsesConfiguredPathOrDefault(t *testing.T) {
out, _, err := runConfigCmd(t, "", "runner-file")
require.NoError(t, err)
assert.Equal(t, ".runner\n", out)
file := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(file, []byte("runner:\n file: 'state dir/registration.json'\n"), 0o600))
out, _, err = runConfigCmd(t, file, "runner-file")
require.NoError(t, err)
assert.Equal(t, "state dir/registration.json\n", out)
}
+37 -6
View File
@@ -25,6 +25,7 @@ import (
"gitea.com/gitea/runner/internal/pkg/lock" "gitea.com/gitea/runner/internal/pkg/lock"
"gitea.com/gitea/runner/internal/pkg/metrics" "gitea.com/gitea/runner/internal/pkg/metrics"
"gitea.com/gitea/runner/internal/pkg/report" "gitea.com/gitea/runner/internal/pkg/report"
"gitea.com/gitea/runner/internal/pkg/telemetry"
"gitea.com/gitea/runner/internal/pkg/ver" "gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect" "connectrpc.com/connect"
@@ -71,6 +72,18 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
defer func() { _ = releaseLock() }() defer func() { _ = releaseLock() }()
} }
shutdownTelemetry, err := telemetry.Setup(ctx, reg.UUID, reg.Name, metrics.Registry)
if err != nil {
log.WithError(err).Warn("OpenTelemetry export failed to start")
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdownTelemetry(ctx); err != nil {
log.WithError(err).Warn("OpenTelemetry export failed to flush")
}
}()
lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels) lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels)
ls := labels.Labels{} ls := labels.Labels{}
@@ -178,6 +191,9 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
return err return err
case err != nil: case err != nil:
log.WithError(err).Error("fail to invoke Declare") log.WithError(err).Error("fail to invoke Declare")
if client.IsRegistrationRejected(err) {
removeEphemeralRegistration(reg, cfg.Runner.File)
}
return err return err
default: default:
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully", log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
@@ -187,12 +203,12 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
poller := poll.New(cfg, cli, runner) poller := poll.New(cfg, cli, runner)
metrics.Init() // OTLP exports the registry even without the server
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
metrics.RegisterUptimeFunc(time.Now())
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
if cfg.Metrics.Enabled { if cfg.Metrics.Enabled {
metrics.Init()
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
metrics.RegisterUptimeFunc(time.Now())
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
metrics.StartServer(ctx, cfg.Metrics.Addr, func() (bool, string) { metrics.StartServer(ctx, cfg.Metrics.Addr, func() (bool, string) {
return poller.Ready(cfg.Metrics.ReadinessGrace) return poller.Ready(cfg.Metrics.ReadinessGrace)
}) })
@@ -202,7 +218,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
defer close(done) defer close(done)
poller.PollOnce() poller.PollOnce(func() { removeEphemeralRegistration(reg, cfg.Runner.File) })
}() }()
// shutdown when we complete a job or cancel is requested // shutdown when we complete a job or cancel is requested
@@ -231,6 +247,10 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name) log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
} }
if poller.RegistrationRejected() {
removeEphemeralRegistration(reg, cfg.Runner.File)
}
if poller.Unregistered() { if poller.Unregistered() {
return errors.New("runner is no longer registered with the server; please register it again") return errors.New("runner is no longer registered with the server; please register it again")
} }
@@ -239,6 +259,17 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
} }
} }
func removeEphemeralRegistration(reg *config.Registration, file string) {
if !reg.Ephemeral {
return
}
if err := os.Remove(file); err == nil {
log.Infof("removed the spent ephemeral registration %q", file)
} else if !os.IsNotExist(err) {
log.WithError(err).Warn("failed to remove the spent ephemeral registration")
}
}
type daemonArgs struct { type daemonArgs struct {
Once bool Once bool
Labels string Labels string
+87
View File
@@ -4,13 +4,24 @@
package cmd package cmd
import ( import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing" "testing"
"time"
"gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/report" "gitea.com/gitea/runner/internal/pkg/report"
"connectrpc.com/connect"
runnerv1 "gitea.dev/actionslib/runner/v1"
"gitea.dev/actionslib/runner/v1/runnerv1connect"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
) )
func TestResolveLabels(t *testing.T) { func TestResolveLabels(t *testing.T) {
@@ -70,3 +81,79 @@ func TestInitLoggingSetsLevelAndCaller(t *testing.T) {
// act plans a job on this logger, so a live task's secrets have to be masked out of it // act plans a job on this logger, so a live task's secrets have to be masked out of it
require.IsType(t, report.MaskingFormatter(nil), log.StandardLogger().Formatter) require.IsType(t, report.MaskingFormatter(nil), log.StandardLogger().Formatter)
} }
var errUnregisteredRunner = errors.New("rpc error: code = Unauthenticated desc = unregistered runner")
type daemonTestService struct {
runnerv1connect.UnimplementedRunnerServiceHandler
declareErr, fetchErr error
beforeTask bool
cancel context.CancelFunc
}
func (s *daemonTestService) Declare(context.Context, *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) {
if s.declareErr != nil {
return nil, s.declareErr
}
return connect.NewResponse(&runnerv1.DeclareResponse{Runner: &runnerv1.Runner{Name: "test"}}), nil
}
func (s *daemonTestService) FetchTask(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) {
if s.fetchErr != nil {
return nil, s.fetchErr
}
if s.beforeTask {
s.cancel()
return nil, context.Canceled
}
return connect.NewResponse(&runnerv1.FetchTaskResponse{Task: &runnerv1.Task{Id: 1, Context: &structpb.Struct{}, WorkflowPayload: []byte("invalid: [")}}), nil
}
func (*daemonTestService) UpdateTask(_ context.Context, req *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
return connect.NewResponse(&runnerv1.UpdateTaskResponse{State: req.Msg.State}), nil
}
func (*daemonTestService) UpdateLog(_ context.Context, req *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil
}
func TestDaemonRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
for _, tc := range []struct {
name string
ephemeral, beforeTask bool
declareErr, fetchErr error
removed bool
}{
{"ephemeral failed task", true, false, nil, nil, true},
{"ephemeral interrupted before task", true, true, nil, nil, false},
{"persistent once", false, false, nil, nil, false},
{"ephemeral rejected on fetch", true, false, nil, errUnregisteredRunner, true},
{"ephemeral proxy 401 on fetch", true, false, nil, connect.NewError(connect.CodeUnauthenticated, errors.New("HTTP status 401 Unauthorized")), false},
{"ephemeral rejected on declare", true, false, errUnregisteredRunner, nil, true},
{"persistent rejected on declare", false, false, errUnregisteredRunner, nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
_, handler := runnerv1connect.NewRunnerServiceHandler(&daemonTestService{declareErr: tc.declareErr, fetchErr: tc.fetchErr, beforeTask: tc.beforeTask, cancel: cancel})
server := httptest.NewServer(http.StripPrefix("/api/actions", handler))
defer server.Close()
dir := t.TempDir()
regFile := filepath.Join(dir, "registration.json")
configFile := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral}))
err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil)
if tc.declareErr == nil && tc.fetchErr == nil {
require.NoError(t, err)
} else {
require.Error(t, err)
}
if tc.removed {
require.NoFileExists(t, regFile)
} else {
require.FileExists(t, regFile)
}
})
}
}
+1 -1
View File
@@ -618,7 +618,7 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout") execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log") execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log")
execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode") execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", config.DefaultImage, "Docker image to use. Use \"-self-hosted\" to run directly on the host.") execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", config.DefaultImage, "Docker image to use. Use \"kubernetes://<image>\" to run it as a pod, \"-self-hosted\" to run directly on the host.")
execCmd.PersistentFlags().StringVarP(&execArg.toolCacheMode, "tool-cache-mode", "", config.ToolCacheModeNone, "What to mount at RUNNER_TOOL_CACHE: none, or shared to reuse one tool cache across runs") execCmd.PersistentFlags().StringVarP(&execArg.toolCacheMode, "tool-cache-mode", "", config.ToolCacheModeNone, "What to mount at RUNNER_TOOL_CACHE: none, or shared to reuse one tool cache across runs")
execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect") execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.") execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.")
+5 -22
View File
@@ -12,7 +12,6 @@ import (
"os/signal" "os/signal"
goruntime "runtime" goruntime "runtime"
"strings" "strings"
"time"
"gitea.com/gitea/runner/internal/app/run" "gitea.com/gitea/runner/internal/app/run"
"gitea.com/gitea/runner/internal/pkg/client" "gitea.com/gitea/runner/internal/pkg/client"
@@ -370,28 +369,12 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
config.RequestTimeout, config.RequestTimeout,
) )
for { if _, err := cli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{
_, err := cli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{ Data: inputs.RunnerName,
Data: inputs.RunnerName, })); err != nil {
})) return fmt.Errorf("cannot ping the Gitea instance server: %w", err)
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if ctx.Err() != nil {
break
}
if err != nil {
log.WithError(err).
Errorln("Cannot ping the Gitea instance server")
// TODO: if ping failed, retry or exit
time.Sleep(time.Second)
} else {
log.Debugln("Successfully pinged the Gitea instance server")
break
}
} }
log.Debugln("Successfully pinged the Gitea instance server")
reg := &config.Registration{ reg := &config.Registration{
Name: inputs.RunnerName, Name: inputs.RunnerName,
+19
View File
@@ -4,7 +4,11 @@
package cmd package cmd
import ( import (
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath"
"sync/atomic"
"testing" "testing"
"gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/config"
@@ -246,3 +250,18 @@ func TestInitInputs(t *testing.T) {
require.Nil(t, inputs.Labels) require.Nil(t, inputs.Labels)
}) })
} }
func TestDoRegisterReturnsPingErrorWithoutRetrying(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
cfg := &config.Config{}
cfg.Runner.File = filepath.Join(t.TempDir(), ".runner")
err := doRegister(t.Context(), cfg, &registerInputs{InstanceAddr: server.URL, RunnerName: "runner"})
assert.ErrorContains(t, err, "cannot ping the Gitea instance server")
assert.Equal(t, int32(1), requests.Load())
}
+12 -8
View File
@@ -52,6 +52,8 @@ type Poller struct {
done chan struct{} done chan struct{}
registrationRejected atomic.Bool
// unregistered is set when the server rejects the runner with an // unregistered is set when the server rejects the runner with an
// Unauthenticated response, meaning the runner is no longer registered. // Unauthenticated response, meaning the runner is no longer registered.
unregistered atomic.Bool unregistered atomic.Bool
@@ -151,7 +153,7 @@ func (p *Poller) Poll() {
} }
} }
func (p *Poller) PollOnce() { func (p *Poller) PollOnce(onTaskFetched func()) {
defer close(p.done) defer close(p.done)
s := &workerState{} s := &workerState{}
for { for {
@@ -173,6 +175,7 @@ func (p *Poller) PollOnce() {
continue continue
} }
s.resetBackoff() s.resetBackoff()
onTaskFetched()
p.runTaskWithRecover(p.jobsCtx, task) p.runTaskWithRecover(p.jobsCtx, task)
return return
} }
@@ -191,6 +194,11 @@ func (p *Poller) Unregistered() bool {
return p.unregistered.Load() return p.unregistered.Load()
} }
// RegistrationRejected reports whether polling stopped on an error matching client.IsRegistrationRejected.
func (p *Poller) RegistrationRejected() bool {
return p.registrationRejected.Load()
}
// Ready reports whether the daemon can currently communicate with Gitea and // Ready reports whether the daemon can currently communicate with Gitea and
// accept work, reusing the availability the poll loop last observed rather than // accept work, reusing the availability the poll loop last observed rather than
// re-running the check. Transient transport failures are tolerated for grace. // re-running the check. Transient transport failures are tolerated for grace.
@@ -368,9 +376,11 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
// An Unauthenticated response means the server no longer knows this // An Unauthenticated response means the server no longer knows this
// runner (e.g. it was deleted). Retrying forever is pointless, so stop // runner (e.g. it was deleted). Retrying forever is pointless, so stop
// polling and let the daemon exit with an error instead of spinning. // polling and let the daemon exit with an error instead of spinning.
if isUnregistered(err) { rejected := client.IsRegistrationRejected(err)
if rejected || connect.CodeOf(err) == connect.CodeUnauthenticated {
log.WithError(err).Error("server rejected the runner as unregistered, stopping poller") log.WithError(err).Error("server rejected the runner as unregistered, stopping poller")
p.unregistered.Store(true) p.unregistered.Store(true)
p.registrationRejected.Store(rejected)
p.shutdownPolling() p.shutdownPolling()
return nil, false return nil, false
} }
@@ -415,12 +425,6 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
return resp.Msg.Task, true return resp.Msg.Task, true
} }
func isUnregistered(err error) bool {
var connectErr *connect.Error
return errors.As(err, &connectErr) && (connectErr.Code() == connect.CodeUnauthenticated ||
connectErr.Code() == connect.CodeUnknown && connect.IsWireError(err) && connectErr.Message() == "rpc error: code = Unauthenticated desc = unregistered runner")
}
func (p *Poller) markHealthyPoll() { func (p *Poller) markHealthyPoll() {
p.lastHealthyPoll.Store(time.Now().UnixNano()) p.lastHealthyPoll.Store(time.Now().UnixNano())
p.lastPollFailed.Store(false) p.lastPollFailed.Store(false)
+21 -5
View File
@@ -106,13 +106,16 @@ func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) {
// response marks the runner as unregistered and cancels the polling context so // response marks the runner as unregistered and cancels the polling context so
// the daemon can exit instead of retrying forever. // the daemon can exit instead of retrying forever.
func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) { func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
for name, fetchErr := range map[string]error{ for name, tc := range map[string]struct {
"connect": connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner")), fetchErr error
"gitea": connect_go.NewWireError(connect_go.CodeUnknown, errors.New("rpc error: code = Unauthenticated desc = unregistered runner")), rejected bool
}{
"connect": {connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner")), false},
"gitea": {connect_go.NewWireError(connect_go.CodeUnknown, errors.New("rpc error: code = Unauthenticated desc = unregistered runner")), true},
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
client := mocks.NewClient(t) client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, fetchErr) client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, tc.fetchErr)
cfg, err := config.LoadDefault("") cfg, err := config.LoadDefault("")
require.NoError(t, err) require.NoError(t, err)
@@ -122,6 +125,7 @@ func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
require.False(t, ok) require.False(t, ok)
assert.True(t, p.Unregistered(), "runner should be marked unregistered") assert.True(t, p.Unregistered(), "runner should be marked unregistered")
assert.Equal(t, tc.rejected, p.RegistrationRejected())
assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff") assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff")
select { select {
case <-p.pollingCtx.Done(): case <-p.pollingCtx.Done():
@@ -330,7 +334,7 @@ func TestPollerPollOnceCallsOnIdle(t *testing.T) {
poller := New(cfg, cli, runner) poller := New(cfg, cli, runner)
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Go(poller.PollOnce) wg.Go(func() { poller.PollOnce(func() {}) })
require.Eventually(t, func() bool { require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0 return runner.idleCalls.Load() > 0
@@ -342,6 +346,18 @@ func TestPollerPollOnceCallsOnIdle(t *testing.T) {
wg.Wait() wg.Wait()
} }
func TestPollerPollOnceCallsOnTaskFetchedBeforeRunningIt(t *testing.T) {
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(connect_go.NewResponse(&runnerv1.FetchTaskResponse{Task: &runnerv1.Task{Id: 1}}), nil)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
runner := &mockRunner{}
runsSeenByCallback := int64(-1)
New(cfg, cli, runner).PollOnce(func() { runsSeenByCallback = runner.running.Load() + runner.totalCompleted.Load() })
assert.Zero(t, runsSeenByCallback)
assert.Equal(t, int64(1), runner.totalCompleted.Load())
}
// TestPoller_ConcurrencyLimitedByCapacity verifies that with capacity=3 and // TestPoller_ConcurrencyLimitedByCapacity verifies that with capacity=3 and
// 6 available tasks, at most 3 tasks run concurrently, and FetchTask is // 6 available tasks, at most 3 tasks run concurrently, and FetchTask is
// never called concurrently (single poller). // never called concurrently (single poller).
+189 -107
View File
@@ -5,11 +5,14 @@ package run
import ( import (
"bytes" "bytes"
"cmp"
"context" "context"
"encoding/base64"
"encoding/json/v2" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"net/url" "net/url"
@@ -34,6 +37,7 @@ import (
"gitea.com/gitea/runner/internal/pkg/labels" "gitea.com/gitea/runner/internal/pkg/labels"
"gitea.com/gitea/runner/internal/pkg/metrics" "gitea.com/gitea/runner/internal/pkg/metrics"
"gitea.com/gitea/runner/internal/pkg/report" "gitea.com/gitea/runner/internal/pkg/report"
"gitea.com/gitea/runner/internal/pkg/telemetry"
"gitea.com/gitea/runner/internal/pkg/ver" "gitea.com/gitea/runner/internal/pkg/ver"
"connectrpc.com/connect" "connectrpc.com/connect"
@@ -41,6 +45,7 @@ import (
runnerv1 "gitea.dev/actionslib/runner/v1" runnerv1 "gitea.dev/actionslib/runner/v1"
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"
) )
// CapabilityCancelling tells the server this runner understands the // CapabilityCancelling tells the server this runner understands the
@@ -68,7 +73,7 @@ type Runner struct {
cacheHandler *artifactcache.Handler cacheHandler *artifactcache.Handler
capabilities string capabilities string
isolatedCacheNetwork func() string isolatedCacheContainer func() string
runningTasks sync.Map runningTasks sync.Map
runningCount atomic.Int64 runningCount atomic.Int64
@@ -94,26 +99,23 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
maps.Copy(envs, cfg.Runner.Envs) maps.Copy(envs, cfg.Runner.Envs)
var cacheHandler *artifactcache.Handler var cacheHandler *artifactcache.Handler
if cacheEnabled(cfg) { if cacheEnabled(cfg) {
if cfg.Cache.ExternalServer != "" { warnIgnoredCachePolicy(cfg)
warnIgnoredCachePolicy(cfg) warnIgnoredCacheSecret(cfg)
// The v1 client appends its path to this without a separator, so the slash is required. handler, err := artifactcache.StartHandler(artifactcache.Options{
envs["ACTIONS_CACHE_URL"] = strings.TrimRight(cfg.Cache.ExternalServer, "/") + "/" Dir: cfg.Cache.Dir,
OutboundIP: cfg.Cache.Host,
Port: cfg.Cache.Port,
Upstream: cfg.Cache.ExternalServer,
S3: cacheS3(cfg),
Policy: CachePolicy(cfg),
Logger: log.StandardLogger().WithField("module", "cache_request"),
})
if err != nil {
log.Errorf("cannot init cache server, it will be disabled: %v", err)
// go on
} else { } else {
warnIgnoredCacheSecret(cfg) cacheHandler = handler
handler, err := artifactcache.StartHandler(artifactcache.Options{ envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
Dir: cfg.Cache.Dir,
OutboundIP: cfg.Cache.Host,
Port: cfg.Cache.Port,
Policy: CachePolicy(cfg),
Logger: log.StandardLogger().WithField("module", "cache_request"),
})
if err != nil {
log.Errorf("cannot init cache server, it will be disabled: %v", err)
// go on
} else {
cacheHandler = handler
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
}
} }
} }
@@ -137,7 +139,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) runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer)
return runner return runner
} }
@@ -151,6 +153,8 @@ var removeOrphanNetworks = container.RemoveOrphanNetworks
var removeOrphanJobVolumes = container.RemoveOrphanJobVolumes var removeOrphanJobVolumes = container.RemoveOrphanJobVolumes
var removeOrphanKubernetesResources = container.RemoveOrphanKubernetesResources
// 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
@@ -173,6 +177,13 @@ func (r *Runner) OnIdle(ctx context.Context) {
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir) r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
} }
r.cleanupOrphanDockerResources(ctx) r.cleanupOrphanDockerResources(ctx)
if r.uuid != "" && r.usesKubernetes() {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
if err := removeOrphanKubernetesResources(ctx, r.kubernetesOptions(nil), r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)); err != nil {
log.Warnf("failed to clean up job pods left behind by earlier jobs: %v", err)
}
}
} }
func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) { func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
@@ -180,7 +191,7 @@ func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
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, r.isolatedCacheContainer(), 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 { if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
@@ -297,6 +308,7 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
ctx, cancel := context.WithTimeout(ctx, r.cfg.Runner.Timeout) ctx, cancel := context.WithTimeout(ctx, r.cfg.Runner.Timeout)
defer cancel() defer cancel()
ctx, endJob := telemetry.StartJob(ctx, task)
// 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 volumeCleanup []common.Executor
@@ -321,6 +333,7 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
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()
endJob(reporter.Result())
}() }()
reporter.RunDaemon() reporter.RunDaemon()
runErr = r.run(ctx, task, reporter) runErr = r.run(ctx, task, reporter)
@@ -347,6 +360,25 @@ func (r *Runner) cloneEnvs() map[string]string {
return envs return envs
} }
// restoreCalledWorkflowContext undoes Gitea's rewrite of `event_name` and `event.inputs` for older runners,
// and returns the job's `inputs` from `gitea_workflow_call`, nil if it is not a called workflow's job.
// See https://github.com/go-gitea/gitea/pull/39452
func restoreCalledWorkflowContext(task *runnerv1.Task) map[string]any {
workflowCall := task.GetContext().GetFields()["gitea_workflow_call"].GetStructValue().GetFields()
if workflowCall == nil {
return nil
}
task.Context.Fields["event_name"] = workflowCall["original_event_name"]
if event := task.Context.Fields["event"].GetStructValue(); event != nil {
if inputs, ok := workflowCall["original_event_inputs"]; ok {
event.Fields["inputs"] = inputs
} else {
delete(event.Fields, "inputs")
}
}
return workflowCall["inputs"].GetStructValue().AsMap()
}
// getDefaultActionsURL // getDefaultActionsURL
// when DEFAULT_ACTIONS_URL == "https://github.com" and GithubMirror is not blank, // when DEFAULT_ACTIONS_URL == "https://github.com" and GithubMirror is not blank,
// it should be set to GithubMirror first. // it should be set to GithubMirror first.
@@ -379,10 +411,36 @@ func (r *Runner) requiresDocker() bool {
return r.labels.RequireDocker() || r.cfg.Container.RequireDocker return r.labels.RequireDocker() || r.cfg.Container.RequireDocker
} }
func (r *Runner) usesKubernetes() bool {
return slices.ContainsFunc(r.labels, func(label *labels.Label) bool { return label.Schema == labels.SchemeKubernetes })
}
// kubernetesOptions layers the pod templates of the runner labels a job runs on, in runs-on order, over the shared one.
func (r *Runner) kubernetesOptions(runsOn []string) container.KubernetesOptions {
options := container.KubernetesOptions{
Kubeconfig: r.cfg.Kubernetes.Kubeconfig,
Namespace: r.cfg.Kubernetes.Namespace,
PodTemplates: []map[string]any{r.cfg.Kubernetes.PodTemplate},
RunnerUUID: r.uuid,
}
names := r.labels.Names()
for index, name := range runsOn {
if template, ok := r.cfg.Kubernetes.PodTemplates[name]; ok && slices.Contains(names, name) && !slices.Contains(runsOn[:index], name) {
options.PodTemplates = append(options.PodTemplates, template)
}
}
return options
}
// fallbackPlatform is where a job runs whose runs-on matches no label, as any job without a // fallbackPlatform is where a job runs whose runs-on matches no label, as any job without a
// runs-on does, since Gitea sends those to every runner. // runs-on does, since Gitea sends those to every runner.
func (r *Runner) fallbackPlatform(ctx context.Context) string { func (r *Runner) fallbackPlatform(ctx context.Context) string {
if r.requiresDocker() || dockerReachable(ctx) { switch {
case r.requiresDocker():
return r.cfg.Runner.DefaultImage
case r.usesKubernetes():
return labels.SchemeKubernetes + "://" + r.cfg.Runner.DefaultImage
case dockerReachable(ctx):
return r.cfg.Runner.DefaultImage return r.cfg.Runner.DefaultImage
} }
return labels.SelfHostedPlatform return labels.SelfHostedPlatform
@@ -395,12 +453,17 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
} }
}() }()
workflowCallInputs := restoreCalledWorkflowContext(task)
r.reportSetup(reporter, task) r.reportSetup(reporter, task)
workflow, jobID, err := generateWorkflow(task) workflow, jobID, err := generateWorkflow(task)
if err != nil { if err != nil {
return err return err
} }
log.WithFields(taskLogFields(task, jobID)).
WithField("actions_url", r.getDefaultActionsURL(task)).
WithField("address", r.client.Address()).
Info("task started")
plan, err := model.CombineWorkflowPlanner(workflow).PlanJob(jobID) plan, err := model.CombineWorkflowPlanner(workflow).PlanJob(jobID)
if err != nil { if err != nil {
@@ -408,13 +471,36 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
} }
job := workflow.GetJob(jobID) job := workflow.GetJob(jobID)
reporter.ResetSteps(len(job.Steps)) reporter.ResetSteps(len(job.Steps))
telemetry.SetJobName(ctx, cmp.Or(job.Name, jobID))
taskContext := task.Context.Fields taskContext := task.Context.Fields
envs := r.cloneEnvs() envs := r.cloneEnvs()
// act asks for the platform once per step, so resolve the fallback at most once per task.
fallbackPlatform := sync.OnceValue(func() string { return r.fallbackPlatform(ctx) })
platformPicker := func(runsOn []string) string {
if platform := r.labels.PickPlatform(runsOn); platform != "" {
return platform
}
return fallbackPlatform()
}
platform := platformPicker(job.RunsOn())
cacheURL, cacheContainer := r.cacheForJob(job, platform)
if cacheURL != "" {
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
}
// Added per task because this job's service containers must be reached directly, and // Added per task because this job's service containers must be reached directly, and
// act reaches them by their workflow key. // act reaches them by their workflow key.
proxyEnv := JobProxyEnv(envs, r.builtInCacheURL(), slices.Sorted(maps.Keys(job.Services))) var services map[string]yaml.Node
_ = job.RawServices.Decode(&services) // a whole-value expression names no service before evaluation
serviceNames := slices.Sorted(maps.Keys(services))
if strings.HasPrefix(platform, labels.SchemeKubernetes+"://") {
for index, name := range serviceNames {
serviceNames[index] = container.KubernetesServiceName(name)
}
}
proxyEnv := JobProxyEnv(envs, cacheURL, serviceNames)
maps.Copy(envs, proxyEnv) maps.Copy(envs, proxyEnv)
if r.capabilities != "" { if r.capabilities != "" {
@@ -423,29 +509,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
if v := taskContext["run_id"].GetStringValue(); v != "" { if v := taskContext["run_id"].GetStringValue(); v != "" {
envs["GITEA_RUN_ID"] = v envs["GITEA_RUN_ID"] = v
} }
telemetry.AddJobEnv(ctx, envs)
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(), preset := model.GithubContextFromMap(task.Context.AsMap())
r.getDefaultActionsURL(task),
r.client.Address())
preset := &model.GithubContext{
Event: taskContext["event"].GetStructValue().AsMap(),
RunID: taskContext["run_id"].GetStringValue(),
RunNumber: taskContext["run_number"].GetStringValue(),
RunAttempt: taskContext["run_attempt"].GetStringValue(),
Actor: taskContext["actor"].GetStringValue(),
Repository: taskContext["repository"].GetStringValue(),
EventName: taskContext["event_name"].GetStringValue(),
Sha: taskContext["sha"].GetStringValue(),
Ref: taskContext["ref"].GetStringValue(),
RefName: taskContext["ref_name"].GetStringValue(),
RefType: taskContext["ref_type"].GetStringValue(),
HeadRef: taskContext["head_ref"].GetStringValue(),
BaseRef: taskContext["base_ref"].GetStringValue(),
Token: taskContext["token"].GetStringValue(),
RepositoryOwner: taskContext["repository_owner"].GetStringValue(),
RetentionDays: taskContext["retention_days"].GetStringValue(),
}
if t := task.Secrets["GITEA_TOKEN"]; t != "" { if t := task.Secrets["GITEA_TOKEN"]; t != "" {
preset.Token = t preset.Token = t
} else if t := task.Secrets["GITHUB_TOKEN"]; t != "" { } else if t := task.Secrets["GITHUB_TOKEN"]; t != "" {
@@ -475,10 +541,8 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// cache requests from the job container can authenticate. The credential // cache requests from the job container can authenticate. The credential
// is removed when the task finishes, so a leaked token stops working as // is removed when the task finishes, so a leaked token stops working as
// soon as the job ends rather than remaining valid for the runner's // soon as the job ends rather than remaining valid for the runner's
// lifetime. Only applies to the embedded cache server; when the operator // lifetime.
// points the runner at an external cache via cfg.Cache.ExternalServer, it revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, cacheURL, reporter)
// is that server's responsibility to authenticate requests.
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)
defer revokeCache() defer revokeCache()
eventJSON, err := json.Marshal(preset.Event) eventJSON, err := json.Marshal(preset.Event)
@@ -511,20 +575,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// Without bind_workdir, the workspace path omits the task id; concurrent host-mode jobs // Without bind_workdir, the workspace path omits the task id; concurrent host-mode jobs
// for the same repository would share this directory and can race with per-job cleanup. // for the same repository would share this directory and can race with per-job cleanup.
// act asks for the platform once per step, so resolve the fallback at most once per task.
fallbackPlatform := sync.OnceValue(func() string { return r.fallbackPlatform(ctx) })
platformPicker := func(runsOn []string) string {
if platform := r.labels.PickPlatform(runsOn); platform != "" {
return platform
}
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) r.setResultsService(envs, resultsURL)
runnerConfig := &runner.Config{ runnerConfig := &runner.Config{
@@ -549,11 +599,13 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
NoSkipCheckout: true, NoSkipCheckout: true,
DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv, DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv,
PresetGitHubContext: preset, PresetGitHubContext: preset,
WorkflowCallInputs: workflowCallInputs,
EventJSON: string(eventJSON), EventJSON: string(eventJSON),
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id), ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
ContainerMaxLifetime: maxLifetime, ContainerMaxLifetime: maxLifetime,
CleanWorkdir: true, CleanWorkdir: true,
ContainerNetworkMode: docker_container.NetworkMode(r.cfg.Container.Network), ContainerNetworkMode: docker_container.NetworkMode(r.cfg.Container.Network),
CacheContainer: cacheContainer,
ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{ ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{
EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4, EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4,
EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6, EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6,
@@ -575,6 +627,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared, SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
InsecureSkipTLS: r.cfg.Runner.Insecure, InsecureSkipTLS: r.cfg.Runner.Insecure,
RunnerName: r.name, RunnerName: r.name,
KubernetesPicker: r.kubernetesOptions,
} }
rr, err := runner.New(runnerConfig) rr, err := runner.New(runnerConfig)
@@ -651,32 +704,24 @@ func (r *Runner) instanceOutOfReach(instance string) bool {
// function the caller must invoke (typically via defer) to revoke the // function the caller must invoke (typically via defer) to revoke the
// credential when the task finishes. // credential when the task finishes.
// //
// Two modes:
// - Embedded handler: register in-process via RegisterJob.
// - external_server: POST to the remote server's /_internal/register, defer a
// POST to /_internal/revoke. This is what enables full per-job auth and
// repo scoping over the network.
//
// Safe with an empty token (older Gitea did not issue one). // Safe with an empty token (older Gitea did not issue one).
// It also returns the URL to advertise as ACTIONS_RESULTS_URL, which is the cache server itself // It also returns the URL to advertise as ACTIONS_RESULTS_URL.
// when it agreed to forward this instance's artifact service, and "" when it did not. func (r *Runner) registerCacheForTask(token, repo, publicURL string, reporter *report.Reporter) (func(), string) {
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) (func(), string) { if token == "" || r.cacheHandler == nil {
if token == "" {
return func() {}, "" return func() {}, ""
} }
cred := artifactcache.JobCredential{ cred := artifactcache.JobCredential{
Repo: repo, Repo: repo,
PublicURL: publicURL,
Results: r.envs["ACTIONS_RESULTS_URL"], // the instance as the job would reach it Results: r.envs["ACTIONS_RESULTS_URL"], // the instance as the job would reach it
InsecureTLS: r.cfg.Runner.Insecure, InsecureTLS: r.cfg.Runner.Insecure,
} }
if r.cacheHandler != nil { revoke := r.cacheHandler.RegisterJob(token, cred)
return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred) if r.cfg.Cache.ExternalServer != "" {
revokeLocal, revokeExternal := revoke, r.registerExternalCacheJob(token, repo, publicURL, reporter)
revoke = func() { revokeLocal(); revokeExternal() }
} }
if cacheEnabled(r.cfg) && r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" { return revoke, r.cacheHandler.ResultsURL(cred)
return r.registerExternalCacheJob(token, cred, reporter)
}
// No cache server to register against: caching is disabled, or the built-in server failed to start.
return func() {}, ""
} }
// registerExternalCacheJob POSTs to the remote cache-server's control-plane. // registerExternalCacheJob POSTs to the remote cache-server's control-plane.
@@ -684,23 +729,19 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
// 401 the job's requests — better than failing the whole task for a cache // 401 the job's requests — better than failing the whole task for a cache
// outage. The warning is mirrored to the job log so users can see why their // outage. The warning is mirrored to the job log so users can see why their
// cache calls 401, instead of having to read the runner daemon's stderr. // cache calls 401, instead of having to read the runner daemon's stderr.
func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) { func (r *Runner) registerExternalCacheJob(token, repo, publicURL string, reporter *report.Reporter) func() {
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/") base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
resultsURL := "" if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, map[string]any{
if body, err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, map[string]any{ "token": token, "repo": repo, "public_url": publicURL,
"token": token, "repo": cred.Repo, "results": cred.Results, "insecure_tls": cred.InsecureTLS,
"public_url": base,
}); err != nil { }); err != nil {
log.Warnf("cache external_server register failed (%s): %v", base, err) log.Warnf("cache external_server register failed (%s): %v", base, err)
if reporter != nil { if reporter != nil {
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf( reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf(
"cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err))) "cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)))
} }
} else if forwarded, _ := body["results_url"].(string); forwarded != "" {
resultsURL = base // the answer only says it forwards, its own address need not be the job's
} }
return func() { return func() {
if _, err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret, if err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
map[string]any{"token": token}); err != nil { map[string]any{"token": token}); err != nil {
log.Warnf("cache external_server revoke failed (%s): %v", base, err) log.Warnf("cache external_server revoke failed (%s): %v", base, err)
if reporter != nil { if reporter != nil {
@@ -708,33 +749,30 @@ func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCr
"cache external_server revoke failed (%s): %v", base, err))) "cache external_server revoke failed (%s): %v", base, err)))
} }
} }
}, resultsURL }
} }
func postInternalCache(url, secret string, body map[string]any) (map[string]any, error) { func postInternalCache(url, secret string, body map[string]any) error {
buf, err := json.Marshal(body) buf, err := json.Marshal(body)
if err != nil { if err != nil {
return nil, err return err
} }
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf)) req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
if err != nil { if err != nil {
return nil, err return err
} }
req.Header.Set("Authorization", "Bearer "+secret) req.Header.Set("Authorization", "Bearer "+secret)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second} client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode/100 != 2 { if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("status %d", resp.StatusCode) return fmt.Errorf("status %d", resp.StatusCode)
} }
answer := map[string]any{} return nil
// A server too old to answer with a body is not an error, it simply tells us nothing.
_ = json.UnmarshalRead(resp.Body, &answer)
return answer, nil
} }
func (r *Runner) RunningCount() int64 { func (r *Runner) RunningCount() int64 {
@@ -830,12 +868,27 @@ func CachePolicy(cfg *config.Config) artifactcache.Policy {
} }
} }
// warnIgnoredCachePolicy flags eviction settings configured on a runner that points at an external cache server. func cacheS3(cfg *config.Config) *artifactcache.S3Options {
s3 := cfg.Cache.S3
if s3 == nil {
return nil
}
return &artifactcache.S3Options{
Endpoint: s3.Endpoint, Region: s3.Region, Bucket: s3.Bucket, Prefix: s3.Prefix,
PathStyle: s3.PathStyle, AccessKey: s3.AccessKey, SecretKey: s3.SecretKey,
}
}
// warnIgnoredCachePolicy flags eviction settings configured on a runner that does not apply them.
func warnIgnoredCachePolicy(cfg *config.Config) { func warnIgnoredCachePolicy(cfg *config.Config) {
defaults := config.DefaultCache() defaults := config.DefaultCache()
if cfg.Cache.Retention != defaults.Retention || cfg.Cache.RepoSizeLimit != defaults.RepoSizeLimit || limits := cfg.Cache.Retention != defaults.Retention || cfg.Cache.RepoSizeLimit != defaults.RepoSizeLimit ||
cfg.Cache.SizeLimit != defaults.SizeLimit || cfg.Cache.SweepInterval != defaults.SweepInterval { cfg.Cache.SizeLimit != defaults.SizeLimit
switch {
case cfg.Cache.ExternalServer != "" && (limits || cfg.Cache.SweepInterval != defaults.SweepInterval):
log.Warn("cache eviction settings are ignored when cache.external_server is set; configure them on that server instead") log.Warn("cache eviction settings are ignored when cache.external_server is set; configure them on that server instead")
case cfg.Cache.S3 != nil && limits:
log.Warn("cache eviction settings are ignored when cache.s3 is set; use lifecycle rules on the bucket instead")
} }
} }
@@ -856,12 +909,22 @@ 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 (r *Runner) cacheIsolatedFrom(job *model.Job, pickPlatform func([]string) string) bool { // cacheForJob returns the cache address for the job, and the container to attach to its network to reach it there.
func (r *Runner) cacheForJob(job *model.Job, platform string) (string, string) {
jobContainer := job.Container() jobContainer := job.Container()
return (jobContainer != nil && jobContainer.Image != "" || pickPlatform(job.RunsOn()) != labels.SelfHostedPlatform) && r.isolatedCacheNetwork() != "" cacheContainer := ""
if !strings.HasPrefix(platform, labels.SchemeKubernetes+"://") && (jobContainer != nil && jobContainer.Image != "" || platform != labels.SelfHostedPlatform) {
cacheContainer = r.isolatedCacheContainer()
}
if cacheContainer == "" {
return strings.TrimSuffix(r.builtInCacheURL(), "/"), ""
}
cacheURL, _ := url.Parse(r.cacheHandler.ExternalURL())
cacheURL.Host = net.JoinHostPort(cacheContainer, cacheURL.Port())
return cacheURL.String(), cacheContainer
} }
func (r *Runner) detectIsolatedCacheNetwork() string { func (r *Runner) detectIsolatedCacheContainer() string {
if r.cacheHandler == nil { if r.cacheHandler == nil {
return "" return ""
} }
@@ -871,9 +934,28 @@ func (r *Runner) detectIsolatedCacheNetwork() string {
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
network, err := container.IsolatedNetwork(ctx, addr, r.cfg.Container.Network) cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network)
if err != nil { if err != nil {
log.Warnf("cannot check whether jobs reach the cache server: %v", err) log.Warnf("cannot check whether jobs reach the cache server: %v", err)
} }
return network return cacheContainer
}
func taskLogFields(task *runnerv1.Task, job string) log.Fields {
taskContext := task.GetContext().GetFields()
fields := log.Fields{
"task_id": task.Id,
"job": job,
"repository": taskContext["repository"].GetStringValue(),
"run_id": taskContext["run_id"].GetStringValue(),
"run_number": taskContext["run_number"].GetStringValue(),
}
if parts := strings.Split(taskContext["gitea_runtime_token"].GetStringValue(), "."); len(parts) == 3 {
var claims struct{ JobID int64 } // Gitea's actionsClaims fields are untagged, so the claim key is "JobID"
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err == nil && json.Unmarshal(payload, &claims) == nil && claims.JobID > 0 {
fields["job_id"] = claims.JobID
}
}
return fields
} }
+53 -80
View File
@@ -15,6 +15,7 @@ import (
"testing" "testing"
"gitea.com/gitea/runner/act/artifactcache" "gitea.com/gitea/runner/act/artifactcache"
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -23,54 +24,13 @@ import (
func emptyCfg() *config.Config { return &config.Config{} } func emptyCfg() *config.Config { return &config.Config{} }
func TestRunner_registerCacheForTask(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
token := "run-token-123"
unregister, _ := r.registerCacheForTask(token, "owner/repo", nil)
base := handler.ExternalURL() + "/_apis/artifactcache"
probe := func() int {
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
assert.NotEqual(t, http.StatusUnauthorized, probe(),
"token should be accepted while task is registered")
unregister()
assert.Equal(t, http.StatusUnauthorized, probe(),
"token must be rejected after the revoker runs")
}
func TestRunner_registerCacheForTask_NoOps(t *testing.T) { func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
t.Run("nil cacheHandler", func(t *testing.T) { for token, handler := range map[string]*artifactcache.Handler{"tok": nil, "": {}} {
r := &Runner{cfg: emptyCfg()}
unregister, _ := r.registerCacheForTask("tok", "owner/repo", nil)
require.NotNil(t, unregister)
unregister()
})
t.Run("empty token", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler} r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
unregister, _ := r.registerCacheForTask("", "owner/repo", nil) unregister, _ := r.registerCacheForTask(token, "owner/repo", "", nil)
require.NotNil(t, unregister) require.NotNil(t, unregister, token)
unregister() unregister()
}) }
} }
// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit // Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit
@@ -81,10 +41,11 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer handler.Close() defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler} r := &Runner{cfg: emptyCfg(), cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
const publicURL = "http://a1b2c3d4e5f6:8088"
token := "full-flow-token" token := "full-flow-token"
unregister, _ := r.registerCacheForTask(token, "owner/repo", nil) unregister, resultsURL := r.registerCacheForTask(token, "owner/repo", publicURL, nil)
defer unregister() assert.Equal(t, publicURL, resultsURL)
base := handler.ExternalURL() + "/_apis/artifactcache" base := handler.ExternalURL() + "/_apis/artifactcache"
do := func(method, url, contentType, contentRange, body string) *http.Response { do := func(method, url, contentType, contentRange, body string) *http.Response {
@@ -137,17 +98,22 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
} }
require.NoError(t, decodeJSON(resp, &hit)) require.NoError(t, decodeJSON(resp, &hit))
require.Equal(t, key, hit.CacheKey) require.Equal(t, key, hit.CacheKey)
require.NotEmpty(t, hit.ArchiveLocation) require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
// download — toolkit does NOT attach Authorization here; the signature // download — toolkit does NOT attach Authorization here; the signature
// in the URL must be enough. // in the URL must be enough.
dl, err := http.Get(hit.ArchiveLocation) dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, handler.ExternalURL(), 1))
require.NoError(t, err) require.NoError(t, err)
defer dl.Body.Close() defer dl.Body.Close()
require.Equal(t, http.StatusOK, dl.StatusCode) require.Equal(t, http.StatusOK, dl.StatusCode)
got := make([]byte, 64) got := make([]byte, 64)
n, _ := dl.Body.Read(got) n, _ := dl.Body.Read(got)
assert.Equal(t, body, string(got[:n])) assert.Equal(t, body, string(got[:n]))
unregister()
resp = do(http.MethodGet, fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), "", "", "")
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
} }
func decodeJSON(resp *http.Response, v any) error { func decodeJSON(resp *http.Response, v any) error {
@@ -155,33 +121,38 @@ func decodeJSON(resp *http.Response, v any) error {
return json.UnmarshalRead(resp.Body, v) return json.UnmarshalRead(resp.Body, v)
} }
// End-to-end against a remote cache-server: token unknown → 401, register → // End-to-end through a runner to a remote cache-server: token unknown → 401, register →
// reserve/upload/commit/find/download all OK, revoke → 401 again. Registering also names the // reserve/upload/commit/find/download all OK, revoke → 401 again.
// instance and the address its jobs reach the server at, which makes a shared cache server the
// whole results service, as the built-in one is.
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) { func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
dir := filepath.Join(t.TempDir(), "remote-cache")
const secret = "shared-secret-for-tests" const secret = "shared-secret-for-tests"
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.2", InternalSecret: secret}) // advertised, never dialled remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InternalSecret: secret})
require.NoError(t, err) require.NoError(t, err)
defer remote.Close() defer remote.Close()
external := strings.Replace(remote.ExternalURL(), "127.0.0.2", "127.0.0.1", 1) gitea := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { assert.Equal(t, "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", req.URL.Path)
assert.Equal(t, "Bearer external-task-token", req.Header.Get("Authorization"))
_, _ = io.WriteString(w, `{"ok":true}`) _, _ = io.WriteString(w, `{"ok":true}`)
})) }))
defer gitea.Close() defer gitea.Close()
r := &Runner{ cli := clientmocks.NewClient(t)
cfg: &config.Config{Cache: config.Cache{ cli.AddressValue = gitea.URL
ExternalServer: external, r := NewRunner(&config.Config{
Runner: config.Runner{Insecure: true},
Cache: config.Cache{
Host: "127.0.0.1",
ExternalServer: remote.ExternalURL() + "//",
ExternalSecret: secret, ExternalSecret: secret,
}}, },
envs: map[string]string{"ACTIONS_RESULTS_URL": gitea.URL}, }, &config.Registration{}, cli)
} t.Cleanup(func() { _ = r.Close() })
require.NotNil(t, r.cacheHandler)
require.Equal(t, r.cacheHandler.ExternalURL()+"/", r.envs["ACTIONS_CACHE_URL"])
const publicURL = "http://a1b2c3d4e5f6:8088"
token := "external-task-token" token := "external-task-token"
repo := "owner/repoX" repo := "owner/repoX"
base := external + "/_apis/artifactcache" base := r.envs["ACTIONS_CACHE_URL"] + "_apis/artifactcache"
probe := func() int { probe := func() int {
req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil) req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
@@ -194,30 +165,24 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, probe(), require.Equal(t, http.StatusUnauthorized, probe(),
"token must be unknown to the remote server before registration") "token must be unknown to the remote server before registration")
disabled := &Runner{cfg: &config.Config{Cache: config.Cache{ unregister, resultsURL := r.registerCacheForTask(token, repo, publicURL, nil)
Enabled: new(bool), ExternalServer: external, ExternalSecret: secret,
}}}
disabled.registerCacheForTask(token, repo, nil)
require.Equal(t, http.StatusUnauthorized, probe(),
"a disabled cache registers nothing, whatever external server is left configured")
unregister, resultsURL := r.registerCacheForTask(token, repo, nil)
require.NotEqual(t, http.StatusUnauthorized, probe(), require.NotEqual(t, http.StatusUnauthorized, probe(),
"token must be accepted after registerCacheForTask") "token must be accepted after registerCacheForTask")
// The server took the results service over, so the artifact half reaches Gitea through it. require.Equal(t, publicURL, resultsURL)
require.Equal(t, external, resultsURL)
artifact, err := http.NewRequestWithContext(t.Context(), http.MethodPost, artifact, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
resultsURL+"/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", nil) r.cacheHandler.ExternalURL()+"/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", nil)
require.NoError(t, err) require.NoError(t, err)
artifact.Header.Set("Authorization", "Bearer "+token) artifact.Header.Set("Authorization", "Bearer "+token)
forwarded, err := http.DefaultClient.Do(artifact) forwarded, err := http.DefaultClient.Do(artifact)
require.NoError(t, err) require.NoError(t, err)
forwarded.Body.Close()
require.Equal(t, http.StatusOK, forwarded.StatusCode) require.Equal(t, http.StatusOK, forwarded.StatusCode)
var artifactResult map[string]bool
require.NoError(t, decodeJSON(forwarded, &artifactResult))
assert.True(t, artifactResult["ok"])
// Full reserve→upload→commit→find→download cycle, identical to what // Full reserve→upload→commit→find→download cycle, identical to what
// @actions/cache does, against the remote (external) server. // @actions/cache does, through the runner to the remote server.
body := []byte("payload-from-task") body := []byte("payload-from-task")
reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))}) reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))})
req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody)) req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody))
@@ -256,14 +221,22 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
ArchiveLocation string `json:"archiveLocation"` ArchiveLocation string `json:"archiveLocation"`
} }
require.NoError(t, decodeJSON(resp, &hit)) require.NoError(t, decodeJSON(resp, &hit))
require.True(t, strings.HasPrefix(hit.ArchiveLocation, external), hit.ArchiveLocation) require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
dl, err := http.Get(hit.ArchiveLocation) dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, r.cacheHandler.ExternalURL(), 1))
require.NoError(t, err) require.NoError(t, err)
defer dl.Body.Close() defer dl.Body.Close()
require.Equal(t, http.StatusOK, dl.StatusCode) require.Equal(t, http.StatusOK, dl.StatusCode)
downloaded, err := io.ReadAll(dl.Body)
require.NoError(t, err)
assert.Equal(t, body, downloaded)
unregister() unregister()
assert.Equal(t, http.StatusUnauthorized, probe(), assert.Equal(t, http.StatusUnauthorized, probe(),
"token must be rejected after the revoker runs") "token must be rejected after the revoker runs")
forwarded, err = http.DefaultClient.Do(artifact)
require.NoError(t, err)
forwarded.Body.Close()
assert.Equal(t, http.StatusUnauthorized, forwarded.StatusCode)
} }
+22 -2
View File
@@ -11,7 +11,9 @@ import (
"testing" "testing"
"time" "time"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/labels"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -311,11 +313,13 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
} }
var swept []string var swept []string
var detached []string
var sweptVolumes []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, detach string, createdBefore time.Time) error {
swept = append(swept, runnerUUID) swept = append(swept, runnerUUID)
detached = append(detached, detach)
sweptCutoff = createdBefore sweptCutoff = createdBefore
return nil return nil
} }
@@ -327,8 +331,21 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
return nil return nil
} }
t.Cleanup(func() { removeOrphanJobVolumes = origRemoveOrphanJobVolumes }) t.Cleanup(func() { removeOrphanJobVolumes = origRemoveOrphanJobVolumes })
var sweptPods []string
origRemoveOrphanKubernetesResources := removeOrphanKubernetesResources
removeOrphanKubernetesResources = func(ctx context.Context, options container.KubernetesOptions, createdBefore time.Time) error {
_, hasDeadline := ctx.Deadline()
assert.True(t, hasDeadline)
sweptPods = append(sweptPods, options.RunnerUUID)
assert.Equal(t, now.Add(-24*time.Hour), createdBefore)
return nil
}
t.Cleanup(func() { removeOrphanKubernetesResources = origRemoveOrphanKubernetesResources })
kubernetesLabel, err := labels.Parse("k8s:kubernetes://node")
require.NoError(t, err)
r := &Runner{uuid: "runner-1", cfg: cfg, now: func() time.Time { return now }} r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{kubernetesLabel}, now: func() time.Time { return now }}
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
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) assert.Equal(t, swept, sweptVolumes)
@@ -340,6 +357,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
dockerReachable = func(context.Context) bool { return false } dockerReachable = func(context.Context) bool { return false }
t.Cleanup(func() { dockerReachable = origDockerReachable }) 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.isolatedCacheContainer = func() string { return "" }
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) assert.Equal(t, swept, sweptVolumes)
@@ -348,4 +366,6 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
now = now.Add(time.Minute) now = now.Add(time.Minute)
hostOnly.OnIdle(context.Background()) hostOnly.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1", "runner-2"}, sweptVolumes) assert.Equal(t, []string{"runner-1", "runner-2"}, sweptVolumes)
assert.Equal(t, []string{"a1b2c3d4e5f6", ""}, detached)
assert.Equal(t, []string{"runner-1"}, sweptPods)
} }
+80 -17
View File
@@ -5,6 +5,7 @@ package run
import ( import (
"context" "context"
"encoding/base64"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -26,6 +27,7 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
runnerv1 "gitea.dev/actionslib/runner/v1" runnerv1 "gitea.dev/actionslib/runner/v1"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -243,6 +245,16 @@ func TestRunnerFallbackPlatform(t *testing.T) {
} }
} }
func TestRunnerKubernetesOptionsLayerTheLabelPodTemplatesInRunsOnOrder(t *testing.T) {
cfg := &config.Config{Kubernetes: config.Kubernetes{
PodTemplate: map[string]any{"base": true},
PodTemplates: map[string]map[string]any{"gpu": {"gpu": true}, "large": {"large": true}, "other": {"other": true}},
}}
r := &Runner{cfg: cfg, labels: labels.Labels{{Name: "linux"}, {Name: "gpu"}, {Name: "large"}}}
require.Equal(t, []map[string]any{{"base": true}, {"large": true}, {"gpu": true}}, r.kubernetesOptions([]string{"linux", "large", "other", "gpu", "large"}).PodTemplates)
}
// Proxy variables are assembled per task, because a job's service containers have to be // Proxy variables are assembled per task, because a job's service containers have to be
// reached directly and they are only known once the workflow is parsed. // reached directly and they are only known once the workflow is parsed.
func TestNewRunnerLeavesProxyToTheTask(t *testing.T) { func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
@@ -251,15 +263,19 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
cfg.Cache.ExternalServer = "http://cache.local:8088/" cfg.Cache.ExternalServer = "http://cache.local:8088/"
cfg.Cache.Host = "runner.local"
reg := &config.Registration{Name: "runner"} reg := &config.Registration{Name: "runner"}
cli := clientmocks.NewClient(t) cli := clientmocks.NewClient(t)
cli.AddressValue = "https://gitea.example/" cli.AddressValue = "https://gitea.example/"
r := NewRunner(cfg, reg, cli) r := NewRunner(cfg, reg, cli)
t.Cleanup(func() { _ = r.Close() })
require.NotContains(t, r.envs, "http_proxy") require.NotContains(t, r.envs, "http_proxy")
require.NotContains(t, r.envs, "no_proxy") require.NotContains(t, r.envs, "no_proxy")
assert.Empty(t, r.builtInCacheURL(), "an external cache server is the operator's to exempt, not ours") proxyEnv := JobProxyEnv(r.envs, r.builtInCacheURL(), nil)
assert.Contains(t, proxyEnv["no_proxy"], "runner.local")
assert.NotContains(t, proxyEnv["no_proxy"], "cache.local")
} }
func taskWithDefaultActionsURL(url string) *runnerv1.Task { func taskWithDefaultActionsURL(url string) *runnerv1.Task {
@@ -289,7 +305,7 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
// The registration is what makes it true: the cache server takes the results service over, // The registration is what makes it true: the cache server takes the results service over,
// having been told which instance to forward the artifact half to. // having been told which instance to forward the artifact half to.
revoke, resultsURL := r.registerCacheForTask(token, "owner/repo", nil) revoke, resultsURL := r.registerCacheForTask(token, "owner/repo", r.cacheHandler.ExternalURL(), nil)
defer revoke() defer revoke()
require.Equal(t, r.cacheHandler.ExternalURL(), resultsURL) require.Equal(t, r.cacheHandler.ExternalURL(), resultsURL)
@@ -341,26 +357,73 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
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: ""}}`)) 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) require.NoError(t, err)
r.isolatedCacheNetwork = func() string { return "compose" } r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] } 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} { 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) cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform(workflow.GetJob(job).RunsOn()))
if isolated {
assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job)
assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job)
} else {
assert.Empty(t, cacheContainer, job)
assert.Equal(t, r.cacheHandler.ExternalURL(), cacheURL, job)
}
} }
} }
// The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured func TestRestoreCalledWorkflowContext(t *testing.T) {
// server that is missing the slash would send it to a URL whose port swallows the path. newTask := func(workflowCall map[string]any) *runnerv1.Task {
func TestNewRunnerNormalizesTheExternalCacheServer(t *testing.T) { values := map[string]any{
cfg := &config.Config{} "event_name": "workflow_call",
cfg.Cache.ExternalServer = "http://cache.local:8088//" "event": map[string]any{"ref": "refs/heads/main", "inputs": map[string]any{"env": "leaf"}},
cli := clientmocks.NewClient(t) }
cli.AddressValue = "https://gitea.example/" if workflowCall != nil {
values["gitea_workflow_call"] = workflowCall
}
taskCtx, err := structpb.NewStruct(values)
require.NoError(t, err)
return &runnerv1.Task{Context: taskCtx}
}
r := NewRunner(cfg, &config.Registration{Name: "runner"}, cli) t.Run("dispatched run", func(t *testing.T) {
task := newTask(map[string]any{
"original_event_name": "workflow_dispatch",
"original_event_inputs": map[string]any{"target": "prod"},
"inputs": map[string]any{"target": "prod", "env": "leaf"},
})
assert.Equal(t, map[string]any{"target": "prod", "env": "leaf"}, restoreCalledWorkflowContext(task))
taskCtx := task.Context.AsMap()
assert.Equal(t, "workflow_dispatch", taskCtx["event_name"])
assert.Equal(t, map[string]any{"ref": "refs/heads/main", "inputs": map[string]any{"target": "prod"}}, taskCtx["event"])
assert.Equal(t, []string{"::group::Inputs", "env: leaf", "target: prod", "::endgroup::"}, inputLines(task.Context.Fields))
})
assert.Equal(t, "http://cache.local:8088/", r.envs["ACTIONS_CACHE_URL"]) t.Run("event without inputs", func(t *testing.T) {
// Nothing to front the results service with, so the variable stays unset and the client keeps task := newTask(map[string]any{
// to v1, which reads the cache URL first. "original_event_name": "push",
assert.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"]) "inputs": map[string]any{"env": "leaf"},
assert.Empty(t, r.envs[runner.CacheServiceV2Env]) })
assert.Equal(t, map[string]any{"env": "leaf"}, restoreCalledWorkflowContext(task))
taskCtx := task.Context.AsMap()
assert.Equal(t, "push", taskCtx["event_name"])
assert.Equal(t, map[string]any{"ref": "refs/heads/main"}, taskCtx["event"])
})
t.Run("not a called workflow's job", func(t *testing.T) {
task := newTask(nil)
assert.Nil(t, restoreCalledWorkflowContext(task))
assert.Equal(t, "workflow_call", task.Context.AsMap()["event_name"])
})
}
func TestTaskLogFields(t *testing.T) {
fields := func(token string) log.Fields {
taskContext, err := structpb.NewStruct(map[string]any{"repository": "owner/repo", "run_id": "159", "run_number": "14", "gitea_runtime_token": token})
require.NoError(t, err)
return taskLogFields(&runnerv1.Task{Id: 72, Context: taskContext}, "build")
}
want := log.Fields{"task_id": int64(72), "job": "build", "repository": "owner/repo", "run_id": "159", "run_number": "14"}
assert.Equal(t, want, fields("malformed"))
want["job_id"] = int64(221)
assert.Equal(t, want, fields("header."+base64.RawURLEncoding.EncodeToString([]byte(`{"TaskID":72,"JobID":221}`))+".signature"))
} }
+7 -2
View File
@@ -56,10 +56,15 @@ func (r *Runner) setupLines(task *runnerv1.Task) []string {
return append(lines, "::endgroup::") return append(lines, "::endgroup::")
} }
// inputLines lists the inputs the run was triggered with, as workflow_dispatch and workflow_call // inputLines lists the inputs of the job: those Gitea resolved for a called workflow's job,
// carry them in the event payload. Empty when the event has none. // or the ones a workflow_dispatch event carries in its payload. Empty when there are none.
func inputLines(fields map[string]*structpb.Value) []string { func inputLines(fields map[string]*structpb.Value) []string {
inputs := fields["event"].GetStructValue().GetFields()["inputs"].GetStructValue().GetFields() inputs := fields["event"].GetStructValue().GetFields()["inputs"].GetStructValue().GetFields()
// For a called workflow's job, restore its inputs from `gitea_workflow_call`.
// See https://github.com/go-gitea/gitea/pull/39452.
if workflowCall := fields["gitea_workflow_call"].GetStructValue(); workflowCall != nil {
inputs = workflowCall.GetFields()["inputs"].GetStructValue().GetFields()
}
if len(inputs) == 0 { if len(inputs) == 0 {
return nil return nil
} }
+1 -1
View File
@@ -15,7 +15,7 @@ import (
) )
func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) { func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) {
workflow, err := model.ReadWorkflow(bytes.NewReader(task.WorkflowPayload)) workflow, err := model.ReadWorkflowPayload(bytes.NewReader(task.WorkflowPayload))
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package action holds the native actions run by `uses: builtin:<name>`.
package action
import (
"gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/model"
)
type Context struct {
Container container.ExecutionsEnvironment
Github *model.GithubContext
Inputs map[string]string
HostWorkdir string
Workspace string // HostWorkdir as seen inside Container
BindWorkdir bool
NoSkipCheckout bool
UseGitIgnore bool
InsecureSkipTLS bool
}
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more