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
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub
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
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
file: ./Dockerfile
+3 -3
View File
@@ -77,10 +77,10 @@ jobs:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
@@ -103,7 +103,7 @@ jobs:
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
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.
- name: pre-pull test images
env:
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:2fe369e969550cde8e867afc3fe370b260140cab4a23d467074295b42163d553 # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # renovate: datasource=docker
run: |
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; 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.
- name: test against dind image
run: make test-dind
- name: test against podman
run: make test-dind TARGET=podman
- name: coverage report
run: |
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
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
`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
ENV TINI_SUBREAPER=1
ENTRYPOINT ["s6-svscan","/etc/s6"]
### DIND-ROOTLESS VARIANT
@@ -56,6 +58,7 @@ VOLUME /data
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
ENV TINI_SUBREAPER=1
USER rootless
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" )
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
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
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"
.PHONY: test-dind
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
@./scripts/test-dind.sh $(TARGET)
test-dind: ## run the daemon-facing tests against the built dind image or podman (TARGET=dind|dind-rootless|podman)
@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
.PHONY: test-e2e
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
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
```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: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:
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.
```bash
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.
> **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.
Versioned and `nightly` tags carry the same suffixes, for example `nightly-dind`. The images target Docker, not Podman.
### Configuration
@@ -201,10 +172,10 @@ A label is written as:
| Part | Meaning |
| --- | --- |
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
| `args` | Only used by the `docker` schema: the image to run the job in. |
| `schema` | `docker`, `kubernetes` or `host`. Defaults to `host` when omitted. |
| `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>`:
@@ -212,6 +183,12 @@ Two schemas are supported:
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:
```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.
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.
@@ -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.
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.
@@ -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.
#### 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`)
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**
@@ -333,10 +314,28 @@ cache:
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.
**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**
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
- 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 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
#### 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.
#### 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`)
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/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json/v2"
"errors"
@@ -17,6 +18,7 @@ import (
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
@@ -57,10 +59,9 @@ type JobCredential struct {
Repo string `json:"repo"`
// 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
// remote runner registers with.
Results string `json:"results"`
InsecureTLS bool `json:"insecure_tls"`
// InsecureTLS how the runner reaches it; see results.go.
Results string `json:"-"`
InsecureTLS bool `json:"-"`
// PublicURL is this server as a reverse proxy makes the job reach it, not the listen address.
PublicURL string `json:"public_url"`
@@ -78,7 +79,8 @@ type credEntry struct {
type Handler struct {
dir string
storage *Storage
storage cacheStorage
s3 *s3Storage
router *httprouter.Router
listener net.Listener
port int
@@ -88,6 +90,9 @@ type Handler struct {
gcing atomic.Bool
gcAt time.Time
stopSync context.CancelFunc
syncing sync.WaitGroup
outboundIP string
// internalSecret guards /_internal/{register,revoke}. When set, a remote
@@ -117,6 +122,8 @@ type Options struct {
Dir string
OutboundIP string
Port uint16
S3 *S3Options
Upstream string
// InternalSecret, when non-empty, enables a control-plane API at
// /_internal/{register,revoke} that lets a remote runner pre-register the
@@ -129,7 +136,7 @@ type Options struct {
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) {
dir, logger := opts.Dir, opts.Logger
h := &Handler{
@@ -147,6 +154,25 @@ func StartHandler(opts Options) (*Handler, error) {
logger = logger.WithField("module", "artifactcache")
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 == "" {
home, err := os.UserHomeDir()
if err != nil {
@@ -165,13 +191,11 @@ func StartHandler(opts Options) (*Handler, error) {
return nil, err
}
h.storage = storage
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.S3 != nil {
if h.s3, err = newS3Storage(storage, *opts.S3); err != nil {
return nil, err
}
h.storage = h.s3
}
secret, err := loadOrCreateSecret(dir)
@@ -201,34 +225,74 @@ func StartHandler(opts Options) (*Handler, error) {
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
// 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
// routable from inside the container network. Authentication is enforced
// 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 {
return nil, err
return err
}
addr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
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
server := &http.Server{
ReadHeaderTimeout: 2 * time.Second,
Handler: router,
Handler: handler,
}
go func() {
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.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 {
@@ -333,6 +397,10 @@ func (h *Handler) Close() error {
if h == nil {
return nil
}
if h.stopSync != nil {
h.stopSync()
h.syncing.Wait()
}
var retErr error
if h.server != nil {
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
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
@@ -408,7 +542,7 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
return nil, err
}
if !ok {
_ = db.Delete(cache.ID, cache)
h.deleteCache(db, cache)
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
@@ -440,7 +574,7 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
now := time.Now().Unix()
cache.CreatedAt = 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)
return
}
@@ -554,6 +688,12 @@ func (h *Handler) commitCache(cache *Cache) error {
cache.Size = written
cache.Complete = true
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()
if err != nil {
@@ -563,6 +703,9 @@ func (h *Handler) commitCache(cache *Cache) error {
if err := db.Update(cache.ID, cache); err != nil {
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
// volume under the floor.
h.evictRepo(db, cache.Repo)
@@ -708,8 +851,7 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
return
}
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, map[string]any{"results_url": h.ResultsURL(body.JobCredential)})
h.responseJSON(w, r, http.StatusOK)
}
// POST /_internal/revoke
@@ -827,7 +969,13 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
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 {
if err := db.TxInsert(tx, bolthold.NextSequence(), cache); err != nil {
return fmt.Errorf("insert cache: %w", err)
@@ -885,6 +1033,8 @@ const (
// a stale reservation.
uploadStallTimeout = 5 * time.Minute
metadataSyncInterval = 30 * time.Second
defaultMinFreeDisk = 1024 * miB
)
@@ -932,6 +1082,9 @@ func (h *Handler) gcCache() {
defer db.Close()
h.evictIncomplete(db)
if h.s3 != nil {
return // lifecycle rules on the bucket expire its entries
}
h.evictExpired(db)
h.evictSuperseded(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
// 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 {
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)
return false
}
+71 -14
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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())
}
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) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(Options{Dir: dir})
@@ -814,7 +864,7 @@ func TestHandler_gcCache(t *testing.T) {
db, err := handler.openDB()
require.NoError(t, err)
for _, c := range cases {
require.NoError(t, insertCache(db, c.Cache))
require.NoError(t, handler.insertCache(db, c.Cache))
}
require.NoError(t, db.Close())
@@ -986,7 +1036,7 @@ func TestHandler_SweepKeepsEntryWhenBlobSurvives(t *testing.T) {
handler := newTestHandler(t, Policy{Retention: testRetention}, cache)
// 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.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.
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
t.Helper()
handler, err := StartHandler(Options{
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})
handler := startTestHandler(t, Options{Policy: policy})
db, err := handler.openDB()
require.NoError(t, err)
for _, e := range entries {
require.NoError(t, insertCache(db, e))
require.NoError(t, handler.insertCache(db, e))
}
require.NoError(t, db.Close())
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.
func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string {
t.Helper()
@@ -1427,8 +1481,8 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
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}
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, insertCache(db, b))
require.NoError(t, handler.insertCache(db, a))
require.NoError(t, handler.insertCache(db, b))
// Write the backing blobs so the dedup deletion has something to remove.
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
_, 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.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.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
+4 -1
View File
@@ -40,6 +40,7 @@ const (
blobUploadURLTTL = time.Hour
twirpInternal = "internal"
twirpUnavailable = "unavailable"
twirpUnauthenticated = "unauthenticated"
)
@@ -111,7 +112,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
CreatedAt: now,
UsedAt: now,
}
if err := insertCache(db, cache); err != nil {
if err := h.insertCache(db, cache); err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
@@ -261,6 +262,8 @@ func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string
switch code {
case twirpInternal:
status = http.StatusInternalServerError
case twirpUnavailable:
status = http.StatusServiceUnavailable
case twirpUnauthenticated:
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
// 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.
//
// 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/"
// 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
}
scheme := r.Header.Get("X-Forwarded-Proto")
if base, err := url.Parse(cred.PublicURL); err == nil && base.Scheme != "" {
scheme = base.Scheme
}
if scheme == "" {
scheme = "http"
}
@@ -98,4 +92,4 @@ func mergeQuery(target, request string) string {
}
// 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.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)
require.Equal(t, http.StatusOK, status, "a registered https public URL onto an http instance must proxy, not redirect")
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
require.Equal(t, http.StatusOK, status, "an http job reaching an https instance must proxy")
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.Equal(t, rpc, gotPath)
@@ -87,15 +87,15 @@ func TestFrontResultsService(t *testing.T) {
},
{
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",
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: "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,
},
{
+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"
"fmt"
"io"
nethttp "net/http"
"os"
"path"
"regexp"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
"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/plumbing"
"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/mattn/go-isatty"
log "github.com/sirupsen/logrus"
@@ -38,6 +42,20 @@ var (
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.
// 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,
@@ -438,10 +456,11 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
return nil, err
}
} else {
for _, refName := range []plumbing.ReferenceName{
plumbing.NewBranchReferenceName(input.Ref),
plumbing.NewTagReferenceName(input.Ref),
} {
refNames := []plumbing.ReferenceName{plumbing.NewBranchReferenceName(input.Ref), plumbing.NewTagReferenceName(input.Ref)}
if input.Ref == plumbing.HEAD.String() || strings.HasPrefix(input.Ref, "refs/") {
refNames = []plumbing.ReferenceName{plumbing.ReferenceName(input.Ref)} // HEAD is the remote's default branch
}
for _, refName := range refNames {
shallowOpts := opts
shallowOpts.Depth = input.Depth
shallowOpts.SingleBranch = true
+24
View File
@@ -6,7 +6,9 @@ package git
import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -756,3 +758,25 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
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"
)
type interfaceIP struct {
net.IP
net.Interface
}
// GetOutboundIP returns an outbound IP address of this machine.
// 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.
@@ -26,11 +31,7 @@ func GetOutboundIP() net.IP {
// So the machine cannot access the internet. Pick an IP address from network interfaces.
if ifs, err := net.Interfaces(); err == nil {
type IP struct {
net.IP
net.Interface
}
var ips []IP
var ips []interfaceIP
for _, i := range ifs {
if addrs, err := i.Addrs(); err == nil {
for _, addr := range addrs {
@@ -42,40 +43,45 @@ func GetOutboundIP() net.IP {
ip = v.IP
}
if ip.IsGlobalUnicast() {
ips = append(ips, IP{ip, i})
ips = append(ips, interfaceIP{ip, i})
}
}
}
}
if len(ips) > 1 {
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 preferredIP(ips)
}
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"
"fmt"
"io"
"path"
"strings"
"sync"
"sync/atomic"
@@ -73,6 +75,9 @@ const (
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
// fragment, missingContainerError composes it into the message every operation shares.
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
}
// 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
type Container interface {
Create(capAdd, capDrop []string) common.Executor
+7 -1
View File
@@ -4,9 +4,10 @@
//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
// * 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
//
// docker/cli is licensed under the Apache License, Version 2.0.
@@ -152,6 +153,7 @@ type containerOptions struct {
runtime string
autoRemove bool
init bool
umask opts.UmaskOpt
annotations *opts.MapOpts
Image string
@@ -326,6 +328,9 @@ func addFlags(flags *pflag.FlagSet) *containerOptions {
flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm")
flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use")
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.SetAnnotation("init", "version", []string{"1.25"})
@@ -721,6 +726,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
MaskedPaths: maskedPaths,
ReadonlyPaths: readonlyPaths,
Annotations: copts.annotations.GetAll(),
Umask: copts.umask.Value(),
}
if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() {
+14 -1
View File
@@ -2,7 +2,7 @@
// Copyright 2022 The nektos/act Authors. All rights reserved.
// 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
// * 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) {
tests := []struct {
input string
+2 -3
View File
@@ -46,14 +46,13 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
}
defer cli.Close()
inspectImage, err := cli.ImageInspect(ctx, imageName)
if cerrdefs.IsNotFound(err) {
if _, err := cli.ImageInspect(ctx, imageName); cerrdefs.IsNotFound(err) {
return false, nil
} else if err != nil {
return false, err
}
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
if _, err = cli.ImageRemove(ctx, imageName, client.ImageRemoveOptions{
Force: force,
PruneChildren: pruneChildren,
}); err != nil {
+67 -13
View File
@@ -16,16 +16,14 @@ import (
"gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/client"
)
const (
networkCreateAttempts = 3
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
@@ -33,17 +31,17 @@ const (
// 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
// 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)
if err != nil {
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
}
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{
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
})
@@ -52,6 +50,7 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
}
var errs []error
networks:
for _, n := range networks.Items {
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
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
// 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
}
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 {
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
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
}
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 {
return func(ctx context.Context) error {
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)
if err != nil {
return "", err
}
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:") {
return "", nil
}
@@ -191,7 +244,7 @@ func isolatedNetwork(ctx context.Context, cli client.APIClient, addr netip.Addr,
if err != nil {
return "", err
}
var holderID string
var holderID, holderContainer string
for _, summary := range containers.Items {
if summary.NetworkSettings == nil {
continue
@@ -199,6 +252,7 @@ func isolatedNetwork(ctx context.Context, cli client.APIClient, addr netip.Addr,
for _, endpoint := range summary.NetworkSettings.Networks {
if endpoint != nil && (endpoint.IPAddress == addr || endpoint.GlobalIPv6Address == addr) {
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 holder.Network.Name, nil
return holderContainer, nil
}
+58 -9
View File
@@ -18,11 +18,12 @@ import (
"github.com/stretchr/testify/require"
)
func TestIsolatedNetwork(t *testing.T) {
func TestIsolatedCacheContainer(t *testing.T) {
ctx := context.Background()
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
client := &mockDockerClient{}
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
ID: "0123456789abcdef",
NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{
"compose": {NetworkID: "c0ffee", IPAddress: cache},
"lan": {NetworkID: "beef", IPAddress: lan},
@@ -38,12 +39,12 @@ func TestIsolatedNetwork(t *testing.T) {
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: inspected}}, nil)
}
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)
assert.Equal(t, want, got, jobNetwork)
}
check(cache, "", "compose")
check(cache, "jobs", "compose")
check(cache, "", "0123456789ab")
check(cache, "jobs", "0123456789ab")
check(cache, "c0f", "")
check(cache, "host", "")
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")))
}
// Of this runner's networks, only the ones nothing 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 (m *mockDockerClient) NetworkConnect(ctx context.Context, id string, opts mobyclient.NetworkConnectOptions) (mobyclient.NetworkConnectResult, error) {
return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
}
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) {
ctx := context.Background()
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: "busy"},
{ID: "starting"},
{ID: "cached"},
{ID: "shared"},
}}, nil)
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
Return(mobyclient.NetworkInspectResult{}, nil)
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
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{}).
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)
}
+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 {
return nil
}
dir, err := filepath.Abs(os.TempDir())
if err != nil {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil
}
daemonDir := dir
seen, err := daemonSeesDir(probeCtx, cli, dir, daemonDir)
if err == nil && !seen {
if dir, daemonDir = runnerContainerWorkdir(probeCtx, cli); daemonDir != "" {
dir, daemonDir := runnerContainerWorkdir(probeCtx, cli)
seen := daemonDir != ""
if !seen {
if dir, err = filepath.Abs(os.TempDir()); err == nil {
daemonDir = dir
seen, err = daemonSeesDir(probeCtx, cli, dir, daemonDir)
}
}
@@ -109,18 +105,16 @@ func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir,
if err != nil {
return "", ""
}
destination := ""
for _, point := range self.Container.Mounts {
if rel, err := filepath.Rel(point.Destination, workdir); err == nil && filepath.IsLocal(rel) && len(point.Destination) > len(destination) {
destination, daemonDir = point.Destination, filepath.Join(point.Source, rel)
}
if daemonDir = containerInfoFromInspect(self.Container).DaemonPath(workdir); daemonDir == "" {
return "", ""
}
if seen, _ := containerSeesMarker(ctx, cli, workdir, self.Container.ID, workdir); !seen { // the hostname may name another container
return "", ""
}
return workdir, daemonDir
}
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir by their path in daemonDir,
// which is what a job's proxy socket mounted from there needs.
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir string) (bool, error) {
func containerSeesMarker(ctx context.Context, cli client.APIClient, dir, id, containerDir string) (bool, error) {
marker, err := os.CreateTemp(dir, "gitea-runner-probe-")
if err != nil {
return false, err
@@ -133,6 +127,16 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
if err := marker.Close(); err != nil {
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{})
if err != nil {
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{
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
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) {
@@ -152,12 +156,13 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
if err != nil {
return false, err
}
seen, err := containerSeesMarker(ctx, cli, dir, created.ID, "/gitea-runner-probe")
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
defer cancel()
if _, err := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); err != nil {
return false, fmt.Errorf("removing the docker proxy probe container failed: %w", err)
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", removeErr)
}
return true, nil
return seen, err
}
// 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)
require.NoError(t, err)
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)
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
@@ -425,6 +428,12 @@ type probeClient struct {
mobyclient.APIClient
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, 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) {
@@ -439,43 +448,47 @@ func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyc
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) {
dir := t.TempDir()
markers := make(map[string]bool)
for _, testCase := range []struct {
name string
private bool
daemonDir string
removeErr error
seen bool
removed bool
}{
{name: "cleanup after cancellation"},
{name: "private filesystem with stale marker", private: true},
{name: "cleanup error after cancellation", removeErr: errors.New("cleanup failed")},
{name: "cleanup after cancellation", daemonDir: dir, seen: true, removed: true},
{name: "daemon without the directory", daemonDir: filepath.Join(dir, "missing")},
{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) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
daemonDir := dir
if testCase.private {
daemonDir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(daemonDir, "probe"), []byte("stale"), 0o600))
}
removed := false
cli := &probeClient{
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
require.Len(t, opts.HostConfig.Mounts, 1)
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())
assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts)
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{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) {
removed = true
assert.Equal(t, "probe", id)
@@ -486,13 +499,45 @@ func TestDaemonSeesDir(t *testing.T) {
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)
assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen)
assert.Equal(t, !testCase.private, removed)
assert.Equal(t, testCase.seen, seen)
assert.Equal(t, testCase.removed, removed)
entries, err := os.ReadDir(dir)
require.NoError(t, err)
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
import (
"archive/tar"
"bytes"
"context"
"errors"
@@ -25,16 +24,12 @@ import (
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector"
"dario.cat/mergo"
"github.com/bmatcuk/doublestar/v4"
cerrdefs "github.com/containerd/errdefs"
"github.com/docker/cli/cli/compose/loader"
"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/moby/moby/api/pkg/stdcopy"
"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,
// 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
}
@@ -629,6 +625,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
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)
binds := hostConfig.Binds
mounts := hostConfig.Mounts
@@ -980,42 +977,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
logger.Error(err)
}
}(tarFile)
tw := tar.NewWriter(tarFile)
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 {
if err := writeDirTar(ctx, tarFile, dstPath, srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID); err != nil {
return err
}
@@ -1040,30 +1002,12 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
if cr.id == "" {
return cr.missingContainerError("copy to %s", dstPath)
}
logger := common.Logger(ctx)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
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 {
if err := writeFilesTar(ctx, &buf, cr.UID, cr.GID, files...); err != nil {
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{
DestinationPath: dstPath,
Content: &buf,
@@ -1261,6 +1205,12 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
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.
func bindTarget(bind string) string {
parsed, err := loader.ParseVolume(bind)
@@ -1291,15 +1241,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
type validVolumeMatcher struct {
allowAll bool
named []*glob.Pattern
host []*glob.Pattern
named []string
host []string
}
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
logger := common.Logger(ctx)
ret := validVolumeMatcher{
named: make([]*glob.Pattern, 0, len(validVolumes)),
host: make([]*glob.Pattern, 0, len(validVolumes)),
named: make([]string, 0, len(validVolumes)),
host: make([]string, 0, len(validVolumes)),
}
for _, v := range validVolumes {
@@ -1308,10 +1258,10 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
continue
}
if !isHostVolumePattern(v) {
if g, err := glob.Compile(v); err != nil {
logger.Errorf("create glob from %s error: %v", v, err)
if doublestar.ValidatePattern(v) {
ret.named = append(ret.named, v)
} else {
ret.named = append(ret.named, g)
logger.Errorf("invalid volume pattern %s", v)
}
continue
}
@@ -1320,10 +1270,10 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
logger.Errorf("normalize volume pattern %s error: %v", v, err)
continue
}
if g, err := glob.Compile(normalized); err != nil {
logger.Errorf("create glob from %s error: %v", normalized, err)
if doublestar.ValidatePathPattern(normalized) {
ret.host = append(ret.host, normalized)
} 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 {
return false
}
for _, g := range m.host {
if g.Match(normalized) {
for _, pattern := range m.host {
if doublestar.PathMatchUnvalidated(pattern, normalized) {
return true
}
}
return false
}
for _, g := range m.named {
if g.Match(source) {
for _, pattern := range m.named {
if doublestar.MatchUnvalidated(pattern, source) {
return true
}
}
+10 -2
View File
@@ -560,11 +560,14 @@ func TestCheckVolumes(t *testing.T) {
"shared_volume",
"/home/test/data",
"/etc/conf.d/*.json",
"/opt/**",
},
binds: []string{
"shared_volume:/shared_volume",
"/home/test/data:/test_data",
"/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",
"/secrets/keys:/keys",
},
@@ -572,6 +575,7 @@ func TestCheckVolumes(t *testing.T) {
"shared_volume:/shared_volume",
"/home/test/data:/test_data",
"/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,
NetworkMode: "bridge",
UsernsMode: "private",
ValidVolumes: []string{"**"},
}}
_, 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)
cr := &containerReference{
input: &NewContainerInput{
NetworkMode: "bridge",
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
NetworkMode: "bridge",
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("", "--shm-size 1g"))
assert.Equal(t, 1, warnings("--network host", ""))
assert.Equal(t, 1, warnings("", "--privileged"))
}
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
+18 -2
View File
@@ -18,7 +18,7 @@ import (
"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
}
@@ -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
}
func SanitizeBinds(_ context.Context, _, binds []string) []string {
return binds
}
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
return nil
}
File diff suppressed because it is too large. Load diff
+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"
"fmt"
"strings"
"sync"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
@@ -25,6 +26,26 @@ var (
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
// to the ref checked out in repoPath.
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 == "" {
ref, err := findGitRef(ctx, repoPath)
ref, err := pinned(ctx, "ref:"+repoPath, func() (string, error) { return findGitRef(ctx, repoPath) })
if err != nil {
logger.Warningf("unable to get git ref: %v", err)
} else {
@@ -95,7 +116,10 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
}
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 {
logger.Warningf("unable to get git revision: %v", err)
} else {
+13
View File
@@ -214,4 +214,17 @@ func TestSetSha(t *testing.T) {
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 (
"context"
"crypto/rand"
"crypto/sha256"
"embed"
"encoding/hex"
@@ -13,6 +14,7 @@ import (
"fmt"
"io"
"io/fs"
"maps"
"os"
"path"
"path/filepath"
@@ -286,11 +288,30 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin
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
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
logger := common.Logger(ctx)
rc := step.getRunContext()
action := step.getActionModel()
if rc.kubernetes {
return errKubernetesDocker
}
var prepImage common.Executor
var image string
@@ -300,7 +321,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
// Apply forcePull only for prebuild docker images
forcePull = rc.Config.ForcePull
} 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))
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)
}
}
eval := rc.NewActionInputsExpressionEvaluator(ctx, step)
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)
cmd, err := shellquote.Split(step.getStepModel().With["args"])
if err != nil {
return err
}
@@ -375,14 +391,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
}
}
}
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
if err != nil {
return err
}
stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
entrypoint, err := dockerEntrypoint(step, stage)
if err != nil {
return err
}
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
return common.NewPipelineExecutor(
prepImage,
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
// 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
var entrypoint string
@@ -406,11 +419,7 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
default:
entrypoint = runs.Entrypoint
if entrypoint == "" {
withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])
if err != nil {
return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
}
if fields := strings.Fields(withEntrypoint); len(fields) > 0 {
if fields := strings.Fields(step.getStepModel().With["entrypoint"]); len(fields) > 0 {
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.
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
rc := step.getRunContext()
stepModel := step.getStepModel()
var err error
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)
}
}
for k, v := range stepModel.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
mergeIntoMap(step, step.getEnv(), inputs, step.getStepModel().With)
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
for k, v := range *step.getEnv() {
if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil {
runsEnv := make(map[string]string, len(action.Runs.Env))
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)
}
}
mergeIntoMap(step, step.getEnv(), runsEnv, maps.Clone(*step.getEnv()))
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()
logWriter := rc.commandLogWriter(ctx)
envList := make([]string, 0)
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, rc.runnerEnv(ctx)...)
binds, mounts, err := rc.GetBindsAndMounts()
if err != nil {
return nil, err
}
binds, mounts := rc.GetBindsAndMounts(ctx)
networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv() {
networkMode = "default"
@@ -493,7 +494,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
AutoRemove: true,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
}), nil
})
}
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 {
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,
EventJSON: parent.EventJSON,
platformImage: parent.platformImage,
kubernetes: parent.kubernetes,
jobIndex: parent.jobIndex,
jobTotal: parent.jobTotal,
}
compositerc.setCompositeActionEnv(env)
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
+54 -3
View File
@@ -12,18 +12,21 @@ import (
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
func TestCompositeActionParity(t *testing.T) {
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
ctx := t.Context()
strategy := &model.Strategy{MaxParallelString: "3"}
strategy := &model.Strategy{MaxParallelString: "3", RawMatrix: yaml.Node{Kind: yaml.MappingNode}}
parent := &RunContext{
Config: &Config{},
Matrix: map[string]any{"os": "linux"},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
JobContainer: &jobContainerMock{},
platformImage: "-self-hosted",
jobIndex: 1,
jobTotal: 4,
}
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
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.True(t, composite.IsHostEnv())
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)
assert.Equal(t, "linux|3|outer", interpolated)
assert.Equal(t, "linux|3|1|outer", interpolated)
assert.NotContains(t, composite.Env, "INPUT_SHARED")
nestedEnv := composite.GetEnv()
@@ -117,3 +120,51 @@ func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) {
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
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
cm.AssertExpectations(t)
@@ -316,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
step.On("getStepModel").Return(&model.Step{ID: "action"})
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.
assert.Empty(t, captured.Username)
@@ -461,18 +461,11 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
case <-innerEntered:
case err := <-done:
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()
releaseOnce()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
}
<-done
}
func TestDockerActionImageTag(t *testing.T) {
@@ -520,6 +513,17 @@ func TestDockerActionImageTag(t *testing.T) {
dockerActionImageTag("owner/repo", "./", 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) {
+4 -1
View File
@@ -14,6 +14,7 @@ import (
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace"
"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
// from cancellation (so the steps run) yet still carry a usable error container.
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()
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.
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.
require.NotPanics(t, func() {
+53 -122
View File
@@ -13,7 +13,6 @@ import (
"reflect"
"regexp"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
@@ -27,29 +26,6 @@ import (
"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
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator {
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
// todo: cleanup EvaluationEnvironment creation
using := make(map[string]exprparser.Needs)
strategy := make(map[string]any)
if rc.Run != nil {
job := rc.Run.Job()
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(),
}
}
strategy = exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal)
// only setup jobs context in case of workflow_call
// 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 {
workflowCallResult = map[string]*model.WorkflowCallResult{}
for jobName, job := range jobs {
for jobName, job := range rc.Run.Workflow.Jobs {
result := model.WorkflowCallResult{
Outputs: map[string]string{},
}
@@ -108,7 +70,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
Vars: getWorkflowVars(ctx, rc),
Strategy: strategy,
Matrix: rc.Matrix,
Needs: using,
Needs: exprparser.NeedsContext(rc.Run),
Inputs: inputs,
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
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`
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
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{
Github: step.getGithubContext(ctx),
Env: *step.getEnv(),
Job: rc.getJobContext(),
Job: jobContext,
Steps: rc.getStepsContext(),
Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc),
Strategy: strategy,
Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal),
Matrix: rc.Matrix,
Needs: using,
Needs: exprparser.NeedsContext(rc.Run),
// todo: should be unavailable
// but required to interpolate/evaluate the inputs in actions/composite
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 {
if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow
return maps.Clone(stepInputs)
}
inputs := map[string]any{}
maps.Copy(inputs, rc.workflowCallInputs)
maps.Copy(inputs, stepInputs)
if ghc.EventName == "workflow_dispatch" {
config := rc.Run.Workflow.WorkflowDispatchConfig()
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)
}
}
// only a called workflow's job itself takes the inputs Gitea resolved,
// composite actions copy its Config but have their own `inputs`
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
maps.Copy(inputs, rc.Config.WorkflowCallInputs)
return inputs
}
if ghc.EventName == "workflow_call" {
config := rc.Run.Workflow.WorkflowCallConfig()
if config != nil && config.Inputs != nil {
for k, v := range config.Inputs {
value := nestedMapLookup(ghc.Event, "inputs", k)
if value == nil {
value = v.Default
maps.Copy(inputs, rc.workflowCallInputs)
switch ghc.EventName {
case "workflow_dispatch":
if config := rc.Run.Workflow.WorkflowDispatchConfig(); config != nil {
for name, input := range config.Inputs {
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
}
// coerceInputValue converts an input value to the type declared by the workflow.
// The event payload carries natively typed JSON values on newer Gitea versions,
// while defaults and older servers provide strings.
func coerceInputValue(value any, inputType string) any {
if inputType != "boolean" {
return value
func setEvaluatorInput(inputs map[string]any, ghc *model.GithubContext, name string, input model.WorkflowCallInput) {
value := model.NestedMapLookup(ghc.Event, "inputs", name)
if value == nil {
value, _ = input.DefaultValue()
}
if b, ok := value.(bool); ok {
return b
}
return value == "true"
inputs[name] = model.CoerceInputValue(value, input.Type)
}
// 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 {
return nil
}
logger := common.Logger(ctx)
callerJob := rc.caller.runContext.Run.Job()
callerEval := rc.caller.runContext.ExprEval
callerWith := callerJob.With
if err := decodeDeferred(ctx, callerEval, "workflow inputs", callerJob.RawWith, &callerWith); err != nil {
callerEval := rc.caller.runContext.ExprEval.shared(ctx)
var callerWith map[string]any
if err := model.DecodeEvaluated("workflow inputs", callerJob.RawWith, callerEval.EvaluateYamlNode, &callerWith); err != nil {
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()
rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
for name, input := range config.Inputs {
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)
var err error
if rc.workflowCallInputs, err = callerEval.ResolveWorkflowCallInputs(config, callerWith); err != nil {
logger.Warn(err)
}
if callerJob.InheritSecrets() {
rc.workflowCallSecrets = getWorkflowSecrets(rc.caller.runContext)
return nil
}
secrets := callerJob.Secrets()
if secrets == nil && callerJob.InheritSecrets() {
secrets = rc.caller.runContext.Config.Secrets
if err := config.ValidateSecrets(secrets); err != nil {
logger.Warn(err)
}
rc.workflowCallSecrets = make(map[string]string, len(secrets))
for k, v := range secrets {
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)
}
if rc.workflowCallSecrets, err = callerEval.EvaluateCallerSecrets(secrets); err != nil {
return fmt.Errorf("unable to interpolate %w", err)
}
return nil
}
+94 -2
View File
@@ -294,6 +294,8 @@ on:
name:
type: string
default: gitea
unset:
type: boolean
`,
"workflow_dispatch": `
on:
@@ -305,6 +307,8 @@ on:
name:
type: string
default: gitea
unset:
type: boolean
`,
}
@@ -356,6 +360,7 @@ on:
inputs := getEvaluatorInputs(rc, nil, ghc)
assert.Equal(t, table.flag, inputs["flag"])
assert.Equal(t, "gitea", inputs["name"])
assert.Equal(t, false, inputs["unset"])
})
}
}
@@ -375,11 +380,11 @@ jobs:
for _, value := range []string{"true", "false"} {
t.Run(value, func(t *testing.T) {
t.Parallel()
parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"runner"}`})
parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"${{ 'runner' }}"}`})
require.NoError(t, err)
child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
require.NoError(t, err)
assert.Equal(t, map[string]any{"flag": value == "true", "name": "runner"}, child.workflowCallInputs)
assert.Equal(t, map[string]any{"flag": value == "true", "name": "${{ 'runner' }}", "unset": false}, child.workflowCallInputs)
assert.Equal(t, rawWith, job.RawWith)
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) {
workflow, err := model.ReadWorkflow(strings.NewReader(`
jobs:
+11
View File
@@ -5,6 +5,7 @@ package runner
import (
"context"
"os"
"os/exec"
"runtime"
"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
// self-hosted (host environment) suite, which runs steps directly on the host.
func requireHostTools(t *testing.T, tools ...string) {
+42 -40
View File
@@ -13,12 +13,12 @@ import (
"errors"
"fmt"
"io"
"maps"
"net/http"
"path"
"slices"
"strconv"
"strings"
"sync"
"time"
"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()))
}
setJobResult(ctx, info, rc, jobError == nil)
rc.removeDockerActionImages(ctx)
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
// 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
// the post steps can run.
// - context.Canceled (server cancel): detach with WithoutCancel under a fresh job-error
// container, so a failing post step cannot turn the cancellation into a failure.
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
// 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.
@@ -291,10 +292,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
switch ctx.Err() {
case context.Canceled:
// The cancelled context is abandoned for a fresh root, which drops the job-error
// 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)
return context.WithTimeout(common.WithJobErrorContainer(context.WithoutCancel(ctx)), 5*time.Minute)
case context.DeadlineExceeded:
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
default:
@@ -414,49 +412,53 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
}
// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
// evaluateJobEnv returns the workflow and job env evaluated, as far as it got on error, leaving rc.Env raw.
func evaluateJobEnv(ctx context.Context, rc *RunContext) (map[string]string, error) {
env := rc.GetEnv()
evaluated := maps.Clone(env)
evaluator := rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
var workflowEnv map[string]string
if err := decodeDeferred(ctx, rc.ExprEval, "workflow env", rc.Run.Workflow.RawEnv, &workflowEnv); err != nil {
return err
}
if workflowEnv != nil {
rc.Env = mergeMaps(workflowEnv, rc.GetEnv())
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
}
var err error
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)
if rc.Run.Workflow.Env == nil {
if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, evaluator.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
return evaluated, err
}
}
defaults := rc.Run.Job().Defaults.Run
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil {
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err)
if workflowEnv != nil {
evaluated = mergeMaps(workflowEnv, env)
evaluator = rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
}
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil {
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err)
var err error
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 {
if rc.caller == nil {
return nil
}
outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs
callerOutputs := make(map[string]string, len(outputs))
ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
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
callerOutputs, err := rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateWorkflowCallOutputs(rc.Run.Workflow.WorkflowCallConfig())
if err != nil {
return fmt.Errorf("unable to interpolate workflow %w", err)
}
// 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 {
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
} 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)
}
}
+2 -2
View File
@@ -479,8 +479,7 @@ func TestNewJobExecutor(t *testing.T) {
Config: &Config{},
}
if tt.output != "" {
rc.Run.Job().Outputs = map[string]string{"bad": tt.output}
rc.outputTemplate = map[string]string{"bad": tt.output}
require.NoError(t, rc.Run.Job().RawOutputs.Encode(map[string]string{"bad": tt.output}))
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
executorOrder := make([]string, 0)
@@ -1082,6 +1081,7 @@ func TestApplyJobTimeout(t *testing.T) {
}{
{"empty", "runs-on: ubuntu-latest", false},
{"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},
}
for _, tc := range cases {
+1 -6
View File
@@ -7,7 +7,6 @@ import (
"cmp"
"context"
"fmt"
"maps"
"path"
"strings"
@@ -51,11 +50,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
rawLogger.Infof("shell: %s", shell)
}
env := map[string]string{}
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
maps.Copy(env, jobContainer.Env)
}
maps.Copy(env, rc.GetEnv())
env := mergeMaps(rc.containerSpec.Env, rc.GetEnv())
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
rc.ApplyExtraPath(ctx, &env)
+35 -82
View File
@@ -9,7 +9,6 @@ import (
"fmt"
"net/url"
"path"
"path/filepath"
"regexp"
"strings"
@@ -20,28 +19,22 @@ import (
)
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
if !rc.Config.NoSkipCheckout {
fullPath := rc.Run.Job().Uses
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),
)
localWorkflow, err := model.ParseReusableWorkflowUses(rc.Run.Job().Uses)
if err != nil {
return common.NewErrorExecutor(err)
}
// ./.gitea/workflows/wf.yml -> .gitea/workflows/wf.yml
trimmedUses := strings.TrimPrefix(rc.Run.Job().Uses, "./")
// uses string format is {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, trimmedUses, rc.Config.PresetGitHubContext.Sha)
if !rc.Config.NoSkipCheckout {
// resolve the local workflow against the workspace root, not the process
// working directory, so it is found regardless of where the runner is invoked
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path)
}
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 := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, uses)
if err != nil {
return common.NewErrorExecutor(err)
}
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
@@ -51,37 +44,27 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
)
}
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
uses := rc.Run.Job().Uses
var remoteReusableWorkflow *remoteReusableWorkflow
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))
}
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, rc.Run.Job().Uses)
if err != nil {
return common.NewErrorExecutor(err)
}
// 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
// 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))
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
return common.NewPipelineExecutor(
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:
// 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
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
@@ -153,64 +136,34 @@ func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) {
}
type remoteReusableWorkflow struct {
URL string
Org string
Repo string
Filename string
Ref string
GitPlatform string
*model.ReusableWorkflowUses
URL 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.
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 {
return fmt.Sprintf("./.%s/workflows/%s", r.GitPlatform, r.Filename)
}
var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
// For Gitea
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow`
// workflows from `.gitea/workflows` and `.github/workflows` are supported
func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow {
// GitHub docs:
// 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
// newRemoteReusableWorkflow parses a remote `uses`, an absolute URL up to `{owner}/{repo}/.{git_platform}/workflows/` replaces baseURL
func newRemoteReusableWorkflow(baseURL, uses string) (*remoteReusableWorkflow, error) {
if matches := absoluteReusableWorkflowURLRegex.FindStringSubmatch(uses); matches != nil {
baseURL, uses = matches[1], matches[2]
}
return &remoteReusableWorkflow{
Org: matches[1],
Repo: matches[2],
GitPlatform: matches[3],
Filename: matches[4],
Ref: matches[5],
URL: url,
parsed, err := model.ParseReusableWorkflowUses(uses)
if err != nil {
return nil, err
}
}
// 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],
if parsed.IsLocal() {
return nil, fmt.Errorf("expected a remote reusable workflow, got %q", uses)
}
return &remoteReusableWorkflow{ReusableWorkflowUses: parsed, URL: baseURL}, nil
}
// For Gitea
+17
View File
@@ -5,6 +5,7 @@ package runner
import (
"context"
"io/fs"
"os"
"os/exec"
"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) {
conf := &Config{
GitHubInstance: "gitea.example.net",
+305 -294
View File
File diff suppressed because it is too large. Load diff
Loaded 50 of 123 files, more files were not shown because too many files have changed in this diff. Show more