Compare commits

..

102 Commits

Author SHA1 Message Date
wmTJc9IK0Q
8b14a38d4a enhance!: send OTLP metrics under OpenTelemetry semantic conventions, add runner state (#1282)
Sends the OTLP metrics under [OpenTelemetry semantic convention](https://opentelemetry.io/docs/specs/semconv/) names where the runner's data matches the definition, like `cicd.worker.count`, `cicd.system.errors` and the `go.*` and `process.*` runtime metrics, and under `gitea.runner.*` otherwise. OTel dashboards then work without remapping, as they already do for the traces from https://gitea.com/gitea/runner/pulls/1206.

Adds a `gitea_runner_state` gauge (`busy`, `idle`, `unavailable`), and `/readyz` now fails once the runner shuts down.

**Breaking**

1. OTLP metric names, units, types and attribute values change, and the info and start time metrics move to resource attributes.
1. Prometheus `gitea_runner_client_errors_total` gains a `code` label, and `gitea_runner_job_total` counts jobs stopped by `runner.timeout` as `timeout` instead of `failure`.

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1282
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: wmTJc9IK0Q <231276+wmtjc9ik0q@noreply.gitea.com>
2026-10-08 18:01:36 +00:00
bircni
179e435cd0 enhance(config): add container.sweep option (#1275)
The Docker proxy labels every container, network and volume a job creates and removes them when the job ends, which breaks jobs that deploy long-running services to the runner's own Docker daemon. Document that behavior and add `container.sweep` (default `true`) so operators who deploy to the same daemon can opt out. Kept resources stay labeled with `com.gitea.runner.job`.

Fixes: https://gitea.com/gitea/runner/issues/1243
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1275
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 17:53:10 +00:00
bircni
f963e9cfe8 enhance: support client certificates for mutual TLS (#1273)
Add `runner.client_cert_file` and `runner.client_key_file` for Gitea instances that require mutual TLS. The runner presents the certificate on its RPCs, job summary uploads, and the action, reusable workflow and `builtin:checkout` clones from the instance. The files are read on use, so renewed certificates apply without a restart. Job steps do not get them. An incomplete or mismatched pair fails startup.

Clones now decide the token, client certificate and `runner.insecure` with one host check, which also gives reusable workflow clones the TLS settings they lacked. `runner.insecure` still only covers the registered address.

Fixes: https://gitea.com/gitea/runner/issues/321
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1273
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 17:48:31 +00:00
bircni
e5aa9add02 fix: apply the daemon's mtu to job networks (#1270)
Copy the `mtu` of Docker's default bridge network to job networks, since Docker applies the daemon's `mtu` setting only to the default bridge. Docker's default of 1500 is not copied, so `default-network-opts` in the daemon config keeps applying.

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

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1270
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 17:39:54 +00:00
bircni
329115b148 enhance(config): expand environment values (#1274)
Config values can reference the runner's environment as `${NAME}`, for example `capacity: ${RUNNER_CAPACITY}`, so container deployments can configure the runner without templating the file. An unset `NAME` fails the load instead of silently becoming empty, and `$${NAME}` writes a literal `${NAME}`. Keys are never expanded.

Breaking: an existing value containing `${NAME}`, such as a shell command in a Kubernetes `pod_template`, now needs `$${NAME}`.

Closes https://gitea.com/gitea/runner/issues/551
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1274
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 17:31:00 +00:00
bircni
ff30f39b1b enhance(runner): add extra HTTP headers (#1269)
Add `runner.extra_headers` for reverse proxies that require request headers on the runner's API requests to Gitea, like registration, polling and log and status reports.

Headers the runner sets itself (authentication, User-Agent, protocol headers) stay authoritative. Extra headers are dropped when a redirect changes the hostname or leaves HTTPS, while the runner's own authentication headers follow redirects as before. Invalid or case-insensitively duplicated header names are rejected at config load.

Fixes: https://gitea.com/gitea/runner/issues/171
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1269
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 17:22:37 +00:00
bircni
3802feb3fe fix: enforce read-only volume restrictions (#1272)
Allow `valid_volumes` entries ending in `:ro` to permit only read-only mounts, so a workflow can no longer mount writable a host path the runner only shares read-only, like a certificate store. `**:ro` allows any volume read-only. An entry without `:ro` that matches too still allows writable mounts.

A named volume allowed only through `:ro` is mounted with `nocopy`, because Docker otherwise fills an empty volume from the image even when it is mounted read-only.

Fixes: https://gitea.com/gitea/runner/issues/407
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1272
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 17:15:41 +00:00
bircni
27e9b4de87 fix!: use the git CLI for action clones and make builtin:checkout match actions/checkout (#1277)
The runner now uses the `git` command line instead of go-git to download actions and reusable workflows. go-git had recurring bugs and does not support SHA-256 repositories, and full clones of actions are several times faster.

`builtin:checkout` now runs `git` where the job runs and behaves like `actions/checkout`:

1. A branch is checked out as a local branch tracking `origin`, tag and pull request runs keep their ref, and a triggering tag that moved fails the step.
1. Later steps can fetch and push with the persisted token or SSH key.
1. It supports the `ssh-*`, `lfs`, `submodules`, `sparse-checkout`, `filter`, `fetch-tags`, `clean`, `persist-credentials`, `show-progress` and `set-safe-directory` inputs and the `ref` and `commit` outputs.
1. SHA-256 repositories work for actions, reusable workflows and `builtin:checkout`.

Git keeps the token off its command line, ignores `GIT_DIR` and similar variables inherited by the runner, and keeps action files LF regardless of `core.autocrlf`. A cancelled job lets git remove its lock files.

This is a breaking change: the runner needs Git 2.34.1 or newer on its `PATH`, and so do job images that use `builtin:checkout`. The official runner images include it.

Fixes: https://gitea.com/gitea/runner/issues/1077
Fixes: https://gitea.com/gitea/runner/issues/1267
Fixes: https://gitea.com/gitea/runner/issues/1279
Fixes: https://gitea.com/gitea/runner/issues/1284
Fixes: https://gitea.com/gitea/runner/issues/1285
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1277
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 17:08:29 +00:00
bircni
4bedadef9d fix: reject workflows with jobs lacking runs-on (#1271)
Fail the workflow before any of its jobs run when a regular job omits `runs-on` or leaves it empty, as GitHub does, instead of running the job in the fallback image. Gitea rejects such workflows itself since https://github.com/go-gitea/gitea/pull/39480. This covers released Gitea versions, which hand a job without labels to any runner, and `exec`. Reusable workflow jobs and the stepless placeholders the runner builds for a job's `needs` are exempt.

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

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1271
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-10-08 09:48:07 +00:00
bircni
83b63250a3 fix: strip log-driver and OOM options when privileged is off (#1283)
When privileged mode is disabled, reset `--log-driver`, `--log-opt`, `--oom-score-adj` and `--oom-kill-disable` from workflow `container.options`. Log drivers run in the host daemon, so a workflow could make it connect to any address, and `--oom-score-adj -1000` exempts a job from the host OOM killer.

Breaking: on unprivileged runners, workflows can no longer set these options, e.g. `--log-driver none` or `--log-opt max-size=10m`. They are dropped with a warning and the job still runs. Runner-level `container.options` and privileged runners are unaffected.

Fixes: https://github.com/go-gitea/gitea/security/advisories/GHSA-cgx2-cw7h-xgcx
Fixes: https://github.com/go-gitea/gitea/security/advisories/GHSA-c988-q5xx-2cff
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1283
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-10-07 19:04:04 +00:00
silverwind
2e59141ae9 fix: allow tmpfs mounts in container options (#1289)
Support tmpfs in container `options` via `--mount type=tmpfs,...`, like GitHub does. The `valid_volumes` check silently dropped these mounts because they have no source. A tmpfs is backed by memory and exposes nothing from the host, same as `--tmpfs`, which already passes. Every other mount still goes through the allowlist.

Both forms now work:

```yaml
services:
  mysql:
    image: mysql:8.4
    options: --tmpfs /var/lib/mysql # already worked
```

```yaml
services:
  mysql:
    image: mysql:8.4
    options: --mount type=tmpfs,destination=/var/lib/mysql # was dropped, now works
```

Reviewed-on: https://gitea.com/gitea/runner/pulls/1289
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-06 19:19:51 +00:00
Drew Inglis
020f0d2861 fix: extract copied directories at their destination in Kubernetes pods (#1286)
The Kubernetes backend extracted CopyDir archives at "/" with entries like
"var/run/act/actions/<sha>/...". Ubuntu's patched tar refuses to extract
through an absolute symlink, failing with "Invalid cross-device link" on
images that link /var/run to /run, so actions never reached the job
container. Extract at the destination with relative entries instead, like
Copy already does.

This is a similar issue to #1128, but for k8s instead of Docker.

I built the image and deployed it to my k8s cluster to verify the fix.

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1286
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Drew Inglis <drew@drewinglis.com>
2026-10-05 11:05:17 +00:00
Renovate Bot
711b72735b chore(deps): update dependencies (#1288)
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1288
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-10-05 04:43:34 +00:00
silverwind
436d0d2942 fix: recognize Gitea's 401 reply for an unregistered runner (#1287)
Since https://github.com/go-gitea/gitea/pull/39578 (backported to v28), Gitea answers an unregistered runner with a Connect `Unauthenticated` error instead of a gRPC status wrapped as `Unknown`. `IsRegistrationRejected` missed it, so an ephemeral runner kept its spent registration file and failed on every restart. This also fails the `gitea/gitea:main-nightly` e2e job.

Match on the `unregistered runner` message suffix, which both shapes share. The code can't distinguish Gitea from a proxy, because a proxy's 401 with a JSON body also decodes as a wire `Unauthenticated` error.

Written by Claude Code.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1287
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-05 03:34:00 +00:00
silverwind
a284a62d96 chore(deps): update gitea.dev/actionslib to v1.3.0 (#1278)
Updates to [actionslib v1.3.0](https://gitea.com/gitea/actionslib/releases/tag/v1.3.0). Matrix include and exclude entries now match combinations with expression equality like GitHub, so an include of `os: LINUX` extends the `os: linux` combination instead of adding a new one. No code changes are needed.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1278
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-02 18:53:13 +00:00
bircni
2b1e64e414 ci: Skip builds on push on main (#1276)
These builds were already tested on pr merge so why run again?

Reviewed-on: https://gitea.com/gitea/runner/pulls/1276
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-10-01 21:21:38 +00:00
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 <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 19:45:13 +00:00
silverwind
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 <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1266
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 16:48:19 +00:00
bircni
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 <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1197
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-01 16:01:14 +00:00
silverwind
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 <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1265
Reviewed-by: bircni <bircni@icloud.com>
2026-10-01 13:22:25 +00:00
wmTJc9IK0Q
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 <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1248
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: wmTJc9IK0Q <231276+wmtjc9ik0q@noreply.gitea.com>
2026-10-01 12:55:16 +00:00
bircni
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 <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1256
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-10-01 09:23:45 +00:00
silverwind
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 <zettat123@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1260
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-10-01 06:59:15 +00:00
bircni
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 <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1257
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-09-30 19:42:52 +00:00
bircni
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 <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1258
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-09-30 18:27:11 +00:00
bircni
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 <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1255
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-09-29 18:44:57 +00:00
bircni
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 <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1259
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-09-29 18:38:35 +00:00
Renovate Bot
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 <2021+silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1254
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-09-28 09:49:36 +00:00
silverwind
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 <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1252
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
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 <39446+zettat123@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
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 <bircni@icloud.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-09-27 16:13:14 +00:00
bircni
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 <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1244
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-09-24 19:46:12 +00:00
silverwind
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 <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1229
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-09-24 18:56:07 +00:00
silverwind
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 <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1214
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-09-24 18:51:12 +00:00
wmTJc9IK0Q
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 <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1206
Reviewed-by: bircni <bircni@icloud.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: wmTJc9IK0Q <231276+wmtjc9ik0q@noreply.gitea.com>
2026-09-24 15:57:18 +00:00
bircni
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 <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/871
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
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 <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-24 14:57:40 +00:00
silverwind
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 <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1233
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-09-23 15:40:19 +00:00
silverwind
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 <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1241
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
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 <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
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 <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
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](53b7df96c9..c3c9e263c2)) | action | digest | `53b7df9` → `c3c9e26` |  |  |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) ([changelog](37fe631027..f87e5991a6)) | action | digest | `37fe631` → `f87e599` |  |  |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) ([changelog](1f40c72289..9901266195)) | 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 <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
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 <xiaolunwen@gmail.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
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 <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
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 <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
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 <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-16 18:40:50 +00:00
silverwind
699a97fc8c enhance: bind-mount job paths through the docker proxy (#1226)
Containers a job starts through its Docker socket, for example `docker run -v "$PWD:/src"`, `./data:/data` in docker compose, or actions like dockerfile-roast, can now bind-mount the workspace and other paths the job sees, as on a host, without `bind_workdir`.

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

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

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

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

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

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

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

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

---------

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

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

---------

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

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

The README caching section is condensed and corrected.

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

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

---------

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

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

---

### Release Notes

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

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

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

#### What's Changed

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

##### Governance

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

##### Enhancements

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

##### Bugfixes

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

#### New Contributors

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

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

</details>

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

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

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

</details>

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

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

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

</details>

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

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

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

#### What's Changed

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

#### New Contributors

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

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

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

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

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

---

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

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/1223
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-09-14 10:58:44 +00:00
silverwind
54978255f5 perf: cut redundant work out of job setup and teardown (#1218)
Implement speedups to job start and shutdown.

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

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

Assisted-by: Claude Code:Opus 5
Reviewed-on: https://gitea.com/gitea/runner/pulls/1218
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-10 19:00:06 +00:00
silverwind
498282caaa fix: IPv6 URLs, cache-server SIGTERM and atomic task admission (#1217)
Three independent fixes, one commit each.

1. Hosts and ports were interpolated directly when building URLs, so a literal IPv6 address produced an unbracketed authority, making `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` and the artifact server listener unusable. Hosts are expected bare, as documented for `cache.host`, so an address that already carries brackets is no longer accepted.
2. `cache-server` waited on its own `os.Interrupt` channel and ignored SIGTERM, so service managers and container runtimes had to kill it.
3. Task admission used a separate `Load` and `Store`, so two concurrent dispatches of the same task id could both be admitted.

Assisted-by: Claude Code:Opus 5
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1217
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-09 15:58:09 +00:00
silverwind
2ed8cdb76e fix: stop artifact uploads depending on the cache server reaching Gitea (#1216)
Cache v2 makes the cache server the `ACTIONS_RESULTS_URL` origin, so artifact calls arrived there and were proxied on to Gitea, failing whenever it could not reach the instance.

- Artifact calls are answered with a redirect, so the cache server opens no connection to Gitea. A scheme change or an untrusted instance is still proxied, but there the cache server is the runner itself, which already reaches Gitea.
- Failures answer in twirp, not an empty `502` that clients report as `Unexpected end of JSON input`.
- `cache.v2: false` really points artifacts at Gitea now.
- Cache reservations are bound to the job that made them, so two jobs saving one key cannot commit against each other's upload, and a retry after a lost answer no longer fails a saved entry.
- The toolkit patch, which edits the GitHub-host check out of an action's bundle, was left in the shared checkout where a job running with `runner.patch_actions: false` could inherit it. It is put back after the job's copy.
- `exec` names an origin for the cache v2 it advertises, and masks its runtime token.

Behaviour changes: `no_proxy` no longer exempts `cache.external_server`, and `cache.enabled: false` also stops external registration.

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

Assisted by Claude (Opus 5).

Reviewed-on: https://gitea.com/gitea/runner/pulls/1216
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-08 18:45:10 +00:00
Zettat123
ff9965e940 fix: stop the job's docker socket from becoming a directory (#1215)
Fixes https://gitea.com/gitea/runner/issues/1213

Fix the DooD regression that mounts `/var/run/docker.sock` as a directory. Keep the Docker proxy available through job and post steps. Clean stale resources before opening it, then remove containers before their networks and volumes during teardown.

Use a unique filesystem probe and preserve socket ownership. Fall back to direct access when proxying is unsupported. Preserve exec output and clean up active streams and failed starts.

Add a real Docker job test for mounted socket access, post steps and resource cleanup.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1215
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
2026-09-08 04:16:02 +00:00
Renovate Bot
ba4d3c5b4f chore(deps): update dependencies and tweak make checks (#1212)
1. Dependency updates: gobwas/glob v1.0.0, which replaced its `Glob` interface with `*Pattern`, logrus v1.10.2, prometheus/client_model v0.6.3, golangci-lint v2.13.2, Go 1.27.1, docker/setup-qemu-action and nginx digests.
2. `make fmt-check` and `make tidy-check` report only what the formatter or `go mod tidy` would change, so unrelated uncommitted work no longer fails `make checks`.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1212
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-09-07 08:20:15 +00:00
silverwind
9e3647395a feat: add GITEA_DOCKER_WORKSPACE and container cleanup (#1204)
1. Add `GITEA_DOCKER_WORKSPACE` which holds the workspace path as the daemon sees it, enabling `${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data` in a compose file without having to resort to `bind_workdir` (which causes much more problems like breaking `actions/cache` because of unstable workspace paths).
2. Add container/network/volume cleanup for containers started within jobs, for example via `docker compose` inside a job. It works by running a lightweight docker socket proxy and injecting a `com.gitea.runner.job` label into every container creation and that label is used to remove containers started by that job at the end. Perf impact of this is near-zero.

Docs: https://gitea.com/gitea/docs/pulls/535

Assisted by Claude (Fable 5.1).
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1204
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-05 08:58:23 +00:00
silverwind
c158ac5472 perf: speed up action downloads (#1209)
An action pinned to a full commit hash cloned the whole repository, and a cached one hit the network on every run. Now only the pinned commit is fetched at depth 1, a cached commit is reused with no network at all, and the action's `.git` directory no longer ships into the job container, matching GitHub.

```
repo                  cold ms         warm ms       cache KiB
actions/checkout      5807 → 787    1064 → 12    11492 → 2349
actions/setup-node   15257 → 1013   1365 → 21    64303 → 9924
actions/cache        18782 → 760    1589 → 16    60890 → 12507
actions/setup-go      5513 → 764     589 → 20    16600 → 9104
docker/login-action  25809 → 1225   1288 → 10    81709 → 12503
```

Reviewed-on: https://gitea.com/gitea/runner/pulls/1209
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-05 08:52:10 +00:00
bircni
1745c7c841 fix: fail the step or job whose expression cannot be interpolated (#1199)
An expression that failed to evaluate was logged and replaced by an empty string, so a `run:` step executed an empty script and reported success. The same swallow covered `shell:`, `working-directory:`, step `env:`, `with:`, `uses:`, the job's `env:`, `container:`, `services:`, `runs-on:` and outputs, and a called workflow's `with:` and `secrets:`. Every interpolation now propagates its error as actions/runner does: step-level values fail the step, job-level values fail the job at setup, `timeout-minutes` logs the error and runs unbounded, and a job or step name keeps its source text.

`defaults.run` and a called workflow's inputs and secrets are resolved once at job setup with the job context rather than per step, and the job's image is resolved once, so host mode and `ImageOS` derive from the image the job started with.

Closes https://gitea.com/gitea/runner/issues/392
Closes https://gitea.com/gitea/runner/issues/555

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1199
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-09-03 10:43:04 +00:00
Renovate Bot
7f53eca716 fix(deps): update module golang.org/x/crypto to v0.56.0 [security] (#1205)
This PR contains the following updates:

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

---

### Prevent DoS on deadlocked undecided channel in golang.org/x/crypto/ssh
[CVE-2026-78662](https://nvd.nist.gov/vuln/detail/CVE-2026-78662) / [GO-2026-6354](https://pkg.go.dev/vuln/GO-2026-6354)

<details>
<summary>More information</summary>

#### Details
Previously, a channel registered in the mux's chanList is not usable until it is established. A malicious peer was able flood the channel's incomingRequests, deadlocking the entire connection.

Now, we add an atomic established state, set when a channel becomes usable. Until such a time, handlePacket drops every packet other than the open confirmation/failure, without blocking and without tearing down the connection.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81316](https://go.dev/issue/81316)
- [https://go.dev/cl/826504](https://go.dev/cl/826504)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6354) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Prevent DoS on deadlocked established channel in golang.org/x/crypto/ssh
[CVE-2026-56855](https://nvd.nist.gov/vuln/detail/CVE-2026-56855) / [GO-2026-6355](https://pkg.go.dev/vuln/GO-2026-6355)

<details>
<summary>More information</summary>

#### Details
Previously, after a channel has been established, a malicious peer could send crafted messages that would deadlock the entire connection.

Now, we handle all RFC 4254 channel messages; global requests are handled explicitly. Then, treat all other messages as a protocol error and tear the connection down instead of buffering and blocking.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81317](https://go.dev/issue/81317)
- [https://go.dev/cl/826524](https://go.dev/cl/826524)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6355) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/1205
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-09-03 06:40:31 +00:00
silverwind
fca6b44c60 fix: automatically add the workspace mount to allowed volumes (#1203)
Automatically allow workspace mounts in `valid_volumes` for the mounts done via `bind_workdir`, this obsoletes the need for `/workspace/**` or other insecure configurations which would expose workspaces between tasks.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1203
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-02 20:32:41 +00:00
bircni
b9018aca31 fix: preserve symlinked Node action entrypoints (#1202)
Node resolves an ESM main to its realpath but leaves `process.argv[1]` as passed. `/var/run/act` reaches the action through the `/var/run` -> `/run` symlink most images ship, so the two disagree and actions comparing them skip their own `run()`. Passing `--preserve-symlinks-main` makes them match.

Trade-off: an action whose `runs.main` is a symlink now resolves dependencies from the link's directory rather than the target's.

Fixes https://gitea.com/gitea/runner/issues/1201
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1202
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@noreply.gitea.com>
2026-08-31 13:06:49 +00:00
Renovate Bot
6c77065295 chore(deps): update dependencies (#1198)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [node](https://github.com/nodejs/node) | digest | `934240a` → `be23f54` |
| [node](https://github.com/nodejs/node) | digest | `3638d9a` → `ba849c6` |

---

### 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/1198
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-29 02:00:21 +00:00
Minjie Fang
235c035003 feat: add input and input-file flags for exec command (#1173)
Add `input` and `input-file` flags for exec command and refactor common code from LoadVars and LoadEnvs.
Closes (https://gitea.com/gitea/runner/issues/1022)

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1173
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Minjie Fang <wingsallen@gmail.com>
2026-08-27 16:38:01 +00:00
silverwind
12dc9d26a2 fix: improve behaviour across masking, commands and status (#1194)
Fixes 51 bugs discovered via comparison with `actions/runner`. Every fix has test coverage.

### Secrets

- A short secret registered no shifted-base64 form, so `base64("user:$TOKEN")` printed in the clear
- Encoded forms came only from the whole trimmed value, missing padded and per-line spellings
- Masks split only on `\n`, so `::add-mask::a%0Db` registered neither half
- Adds XML, expression-string and quote-trimming encoders

### Workflow commands

- Split at the last `::` or `]` rather than the first, so `::add-mask::a::b` registered no mask
- A command on the last line without a newline was ignored, and `::ADD-MASK::` did nothing
- `##[...]` did not decode `%3B`/`%5D`, properties lost anything after a second `=`
- `$GITHUB_ENV` and `::set-env::` now refuse `NODE_OPTIONS`

### Status

- `continue-on-error` reported failed, a cancelled job reported success, an `if:` error reported cancelled
- File commands ran after `continue-on-error`, failing the job while the step stayed green
- A bad job output aborted the whole run instead of that job

### Steps and actions

- `${{ matrix.* }}` and `${{ strategy.* }}` were empty inside composite actions
- Composite inputs leaked into nested actions as `INPUT_*`, `with:` matched case-sensitively, `pre` failures were dropped
- Docker actions dropped `runs.env` when the caller passed `with: args:`, and caller `args`/`entrypoint` beat the manifest
- An implicit shell ran with `pipefail`, a `shell:` without `{0}` passed without running
- `container.env` overrode job env and every `$GITHUB_ENV` write, heredocs lost leading blank lines, `$GITHUB_PATH` was not BOM-decoded

Written by Claude Opus 5.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1194
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-27 09:36:27 +00:00
silverwind
d9f4d65545 fix: honor volumes declared on service containers (#1186)
Service containers were built without a volume policy, so every bind and mount they declared was dropped, whatever `valid_volumes` allowed. GitHub passes a service's declared volumes straight to `docker create`, so a workflow that mounts into a service silently did nothing here. Services now get the configured policy, but not `validVolumes()`, which would also hand them the docker daemon socket that GitHub mounts only into the job container.

### What changes for users

On the default `valid_volumes: []` nothing changes: a service's volumes are still dropped, now with a warning rather than in silence. Once `valid_volumes` is configured, a service's declared volumes are honored under it instead of discarded, which is what that setting already documents. No workflow that worked before stops working, and a service can reach no volume the policy does not already allow the job container, so this is not a breaking change.

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1186
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-26 15:55:27 +00:00
ABiscuitttt
212909db7b fix: keep a step's own with: values out of its inputs context (#1192)
A step's `if:`, its `continue-on-error:` and its `run:` script resolved `inputs.*` from the step's own `INPUT_*` env. A `with:` key colliding with a workflow input flipped conditions, and any `INPUT_`-shaped variable from `env:` or a `GITHUB_ENV` write forged an input that never existed.

GitHub evaluates all three in the enclosing scope: the workflow inputs, or for a composite action's steps that action's inputs. Action-input interpolation is the one place that legitimately sees a step's own `with:`, so it keeps its own evaluator.

Fixes https://gitea.com/gitea/runner/issues/1191, ports https://github.com/nektos/act/pull/2473 and extends it to the pre and post stages.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1192
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: ABiscuitttt <773542570@qq.com>
2026-08-26 14:45:05 +00:00
silverwind
0712b2a7a1 fix: mask secrets on every path they leave a job (#1188)
A secret in a matrix value reached the log in the clear:

```yaml
strategy:
  matrix:
    include: "${{ github.token }}"
```

Chasing that one route is pointless, so this masks every sink a secret leaves a job by: the uploaded log rows and the on-disk `job.log`, both through one choke point in `appendLogRow`; the runner's own log, which is where planning errors like that one land with no job logger in reach; the job logger's stdout under debug logging; job summaries; job outputs; and the job name that becomes a container name.

Values the runner knows but the job never declared, the proxy password and the task token, are hidden the same way. Masks apply longest first, since `strings.Replacer` matches in argument order and one secret prefixing another would otherwise mask the prefix and print the rest.

### What changes for users

An output whose value carries a secret is skipped with a warning instead of sent, matching GitHub. Output that showed a secret now shows `***`. `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG` are never masked, also matching GitHub, so an output of `true` still reaches the jobs that need it.

Each fix has a test that fails without it.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1188
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-25 20:36:05 +00:00
bircni
34df4887af fix: report step log ranges with the log flush (#1189)
Gitea slices a task's single log stream into steps by the LogIndex/LogLength that UpdateTask carries. The reporter's daemon flushed log rows without those counters, which only left on the separate state ticker, so rows the server acked between two state reports belonged to no step. The web UI attributes them to no step while the job runs, and a runner that stops reporting in that window leaves them under "Complete job" for good once Gitea finalizes the task.

The three log-flush paths now report the state describing the rows the server just took, so the gap is at most one RPC. Live output also stops waiting up to `state_report_interval` to become visible. An idle job adds no requests.

This does not explain why the runner in that job stopped reporting for 13m40s until Gitea reaped the task as a zombie, that needs the runner host's own log.

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

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1189
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-08-25 12:13:18 +00:00
silverwind
745a1e70e6 fix: keep the runner's own container.options when privileged is off (#1151)
The runner's own `container.options` and the workflow's were joined into one string before parsing, so the host-escape filter added in https://gitea.com/gitea/runner/pulls/1058 dropped the administrator's options along with the workflow's. Setups that need `--device` or `--security-opt` from the config file had no way left to get them short of enabling privileged mode.

`NewContainerInput` now carries the two sources apart, as `RunnerOptions` and `WorkflowOptions`, down to the point where the filter runs. With privileged mode off, the host-escape fields are reset to what the runner's own options parse to on their own, so only the workflow's contribution is dropped.

Three further ways a workflow's options reached past its container, all resolved on the runner before anything reaches the daemon:

1. `--env-file` and `--label-file` name files that are read on the runner, so any file it could read became container environment or labels. Both are refused from a workflow now, and still serve the runner's own options.
2. A bare `--env NAME` was resolved from the runner's own environment by docker's validator. That lookup is gone, for every source. Use `runner.envs` or `runner.env_file` to pass a variable on.
3. A volume driver decides for itself what it mounts, and the local driver's `device=` option turns a name `valid_volumes` allows into a bind of any host path. A workflow's mounts may no longer carry one.

`--isolation`, `--volume-driver` and the two paths `--security-opt systempaths=unconfined` lands in were also missing from the fields a workflow may not set.

Last, the `--network and --net in the options will be ignored.` warning fired for every container, because the runner's own network mode is fed into the parsed options before the check runs.

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/1151
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-24 22:13:28 +00:00
Renovate Bot
e30c2fed62 chore: update deps, adapt lint, use json v2 (#1185)
- Raised go to 1.27
- Adopted json v2
- Sync lint config from gitea
- Fixed all issues

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1185
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-24 19:53:17 +00:00
silverwind
7b4356c746 fix: fail the run when matrix expansion fails (#1187)
A `GetMatrixes` error was logged and discarded, leaving a nil matrix list. That collapsed `maxParallel` to zero, so no executor was built and the parallel executor returned nil for an empty list: the job reported success without running anything.

It now fails the run. Every error it returns is a workflow validation failure that GitHub rejects too, so nothing that runs there starts failing here.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1187
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-24 17:03:45 +00:00
Renovate Bot
e78123cee3 fix(deps): update go toolchain directive to v1.26.6 [security] (#1183)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [go](https://go.dev/) ([source](https://github.com/golang/go)) | toolchain | patch | `1.26.5` → `1.26.6` |

---

### Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna
[CVE-2026-39821](https://nvd.nist.gov/vuln/detail/CVE-2026-39821) / [GO-2026-5026](https://pkg.go.dev/vuln/GO-2026-5026)

<details>
<summary>More information</summary>

#### Details
The ToASCII and ToUnicode functions incorrectly accept Punycode-encoded labels that decode to an ASCII-only label. For example, ToUnicode("xn--example-.com") incorrectly returns the name "example.com" rather than an error.

This behavior can lead to privilege escalation in programs using the idna package. For example, a program which performs privilege checks on the ASCII hostname may reject "example.com" but permit "xn--example-.com". If that program subsequently converts the ASCII hostname to Unicode, it will inadvertently permits access to the Unicode name "example.com".

#### Severity
Unknown

#### References
- [https://go.dev/cl/767220](https://go.dev/cl/767220)
- [https://go.dev/issue/78760](https://go.dev/issue/78760)
- [https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8](https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-5026) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Parsing an invalid SVCB or HTTPS RR can panic in golang.org/x/net/dns/dnsmessage
BIT-golang-2026-46600 / [CVE-2026-46600](https://nvd.nist.gov/vuln/detail/CVE-2026-46600) / [GO-2026-5942](https://pkg.go.dev/vuln/GO-2026-5942)

<details>
<summary>More information</summary>

#### Details
Parsing an invalid SVCB or HTTPS RR can panic when the size of a parameter value overflows the message buffer.

#### Severity
Unknown

#### References
- [https://go.dev/cl/786345](https://go.dev/cl/786345)
- [https://go.dev/issue/79795](https://go.dev/issue/79795)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-5942) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Enforce maximum recursion depth in encoding/asn1
BIT-golang-2026-33818 / [CVE-2026-33818](https://nvd.nist.gov/vuln/detail/CVE-2026-33818) / [GO-2026-5972](https://pkg.go.dev/vuln/GO-2026-5972)

<details>
<summary>More information</summary>

#### Details
Enforce a recursion limit in Unmarshal to prevent stack exhaustion when parsing deeply-nested, recursive structures.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80405](https://go.dev/issue/80405)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)
- [https://go.dev/cl/814980](https://go.dev/cl/814980)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-5972) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Add recursion depth guard during decode in encoding/xml
BIT-golang-2026-56859 / [CVE-2026-56859](https://nvd.nist.gov/vuln/detail/CVE-2026-56859) / [GO-2026-6088](https://pkg.go.dev/vuln/GO-2026-6088)

<details>
<summary>More information</summary>

#### Details
Previously, DecodeElement would reset the depth counter causing it to never fire; this could lead to stack exhaustion.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80481](https://go.dev/issue/80481)
- [https://go.dev/cl/803320](https://go.dev/cl/803320)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6088) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Apply ReadHeaderTimeout when doing unencrypted HTTP/2 check in net/http
BIT-golang-2026-56853 / [CVE-2026-56853](https://nvd.nist.gov/vuln/detail/CVE-2026-56853) / [GO-2026-6089](https://pkg.go.dev/vuln/GO-2026-6089)

<details>
<summary>More information</summary>

#### Details
When a server is configured to support unencrypted HTTP/2, it reads a few bytes from each new connection to see if they contain the HTTP/2 client preface. ReadHeaderTimeout is unexpectedly not being applied when doing this.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80205](https://go.dev/issue/80205)
- [https://go.dev/cl/795540](https://go.dev/cl/795540)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6089) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Limit handshake messages we are willing to accept post-handshake in crypto/tls
BIT-golang-2026-56862 / [CVE-2026-56862](https://nvd.nist.gov/vuln/detail/CVE-2026-56862) / [GO-2026-6090](https://pkg.go.dev/vuln/GO-2026-6090)

<details>
<summary>More information</summary>

#### Details
Handshake messages, such as KeyUpdate, are always considered as state-advancing, regardless of whether a handshake has been completed or not. As a result, a malicious client can keep sending KeyUpdate messages to force the server to keep performing key derivation operations indefinitely.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80528](https://go.dev/issue/80528)
- [https://go.dev/cl/804261](https://go.dev/cl/804261)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6090) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Fix Javascript regexp context tracking in html/template
BIT-golang-2026-56858 / [CVE-2026-56858](https://nvd.nist.gov/vuln/detail/CVE-2026-56858) / [GO-2026-6091](https://pkg.go.dev/vuln/GO-2026-6091)

<details>
<summary>More information</summary>

#### Details
Previously, pathological inputs could close an unescaped '/' early, allowing for attack-controlled data to inject arbitrary content, potentially leading to XSS.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80435](https://go.dev/issue/80435)
- [https://go.dev/cl/807100](https://go.dev/cl/807100)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6091) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Avoid quadratic complexity in resolvePath in net/url
BIT-golang-2026-56860 / [CVE-2026-56860](https://nvd.nist.gov/vuln/detail/CVE-2026-56860) / [GO-2026-6218](https://pkg.go.dev/vuln/GO-2026-6218)

<details>
<summary>More information</summary>

#### Details
Previously, resolving relative paths containing parent directory ('..') segments performed string conversions and buffer rewrites on each step, resulting in quadratic time complexity and high memory allocation overhead.

Now, path resolution operates on a byte buffer using index-based backtracking for '..' segments, eliminating the quadratic time complexity and significantly reducing memory allocations.

#### Severity
Unknown

#### References
- [https://go.dev/cl/803681](https://go.dev/cl/803681)
- [https://go.dev/issue/80494](https://go.dev/issue/80494)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6218) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/1183
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-23 12:46:12 +00:00
bircni
8260e2def2 test: add end-to-end Gitea compatibility suite (#1180)
Adds a real-Gitea compatibility suite against stable and nightly to catch runner and API drift before release.

The suite shares one Gitea and regular runner, using repository runners only for cache v1/v2 and ephemeral behavior. It covers registration, payload and log encoding, secrets, variables, services, artifacts, outputs, matrices, cache, dispatch, live logs, cancellation, and ephemeral teardown.

CI runs both images in parallel. Warm local timings:

| Image | Before | After |
| --- | ---: | ---: |
| `gitea/gitea:latest` | ~70s | 26.54s |
| `gitea/gitea:main-nightly` | ~54s | 22.53s |

The former serial matrix took about 2 minutes. Parallel suite execution is now bounded by the slower ~26.5-second variant.

Shared Renovate matcher: https://gitea.com/gitea/renovate-config/pulls/552

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1180
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-08-23 12:32:22 +00:00
silverwind
b97c61aa14 test: speed up tests (#1181)
Parallelize isolated workflow tests, consolidate redundant fixtures, and replace fixed waits with deterministic synchronization. Keep the readable curl service probe and use `getent` for hostname resolution.

Measured on the same machine with `make test`:

1. Wall time: 170.50s to 136.12s, down 34.38s or 20.2%.
1. `act/runner`: 138.417s to 124.291s, down 14.126s or 10.2%.
1. `act/runner` coverage: unchanged at 85.2%.
1. `TestDockerExecAbort`: 2.514s to 0.012s package time.

Stability checks:

1. Cancellation and deadline tests: 100 race-enabled repetitions.
1. Host runner suite: 10 race-enabled repetitions.
1. Changed Docker fixtures: 3 consecutive repetitions.

Full race suite, Go and Windows lint, source checks, and security scan pass.

Assisted-by: Codex:GPT-5
Reviewed-on: https://gitea.com/gitea/runner/pulls/1181
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-22 20:53:55 +00:00
silverwind
546eca312e refactor: remove unreachable runner code (#1179)
Remove inherited act APIs, configuration branches, and test seams that neither the daemon nor exec can reach. Constant-fold settings both entry points already enforce and consolidate duplicate runner paths.

Major removals:

- Unwired custom action-cache and local-repository-cache implementations.
- Legacy matrix, platform, input, container-reuse, logging, Git remote, and action-replacement configuration paths.
- Unused Docker socket, container network, tar-copy, and platform PTY wrappers.
- Single-implementation filesystem, environment, runner, and expression abstractions.
- Duplicated step-container, command-logging, credential, reusable-workflow, and execution paths.
- Generated client mock boilerplate, obsolete fixtures, test-only seams, stale wrappers, and commented-out code.

This removes 2844 net Go lines while retaining Gitea RPC, event, matrix, input, cache, artifact, action, reusable workflow, Docker, host, exec, and release behavior.

Assisted by Codex (GPT-5).

Reviewed-on: https://gitea.com/gitea/runner/pulls/1179
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-22 16:54:41 +00:00
bircni
a0c4de79f7 feat: add log.job.dir (#1165)
`log.job.dir` makes the runner write a copy of every task's log to that directory, as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same masking and the job's result on the last line. Off by default, and what Gitea shows does not change.

`log.job.retention` (default `168h`) and `log.job.max_size` (default `1GB`) bound the directory. Documented in the README.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1165
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-08-21 07:25:11 +00:00
silverwind
dfe979e1d0 fix: stop a failed step disabling the toolkit patch (#1177)
A step that failed, for any reason, made the runner restore the action's stock bundle and mark it never to be patched again. Every later `actions/upload-artifact` run then failed with `GHESNotSupportedError`, and nothing in the log said why.

The edit is now made as the action is copied into the job container, under the lock that guards the copy, and nothing reverts it. That also closes the race where another job's checkout reset the bundle mid-job.

`cache.v2` no longer decides whether the edit is made, it only withdraws the v2 advertisement, so artifacts work whatever the cache is set to.

Also added a new `runner.patch_actions` option to turn the edit off if it ever breaks an action.

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/1177
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-20 15:46:33 +00:00
bircni
11ac12efa4 enhance: add runner.default_image for jobs matching no label (#1164)
A job whose `runs-on` matches none of the runner's labels, which includes every job that sets no `runs-on` at all, runs in `runner.default_image`. It defaults to `docker.gitea.com/runner-images:ubuntu-latest` as before, so a mirror can be pointed at instead.

A runner with no reachable docker daemon now runs such a job on the host, rather than failing on an image it cannot pull. Runners that use docker are unaffected and never probe for one.

This matters most to a host-mode runner, one whose labels are all `host`. Such a runner has no daemon to pull an image with, so a job matching none of its labels used to fail at container start. It now runs on the host, where that runner runs everything else anyway, and it takes no configuration to get there. A host-mode runner that does have a daemon within reach keeps using the image, unchanged.

Supersedes https://gitea.com/gitea/runner/pulls/642

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1164
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-08-19 18:36:00 +00:00
silverwind
bc8161c673 fix: bound blocking calls and stop failing silently (#1174)
Jobs occasionally go silent ([example](https://gitea.com/gitea/runner/actions/runs/805045/jobs/1055123)) mid-run and Gitea reaped them after `ZOMBIE_TASK_TIMEOUT`, with no error in the log. This contains a number of related fixes, all with full test coverage:

1. Bound every RPC to Gitea with a timeout, a stalled report otherwise parked logs and heartbeats for the whole job.
2. Cap `runner.fetch_timeout` at that ceiling.
3. Let only the daemon loop close its own channel, the race panicked the process.
4. Stop the job on any terminal server result, not just `RESULT_CANCELLED`.
5. Report that result instead of relabelling it as cancelled.
6. Log reporting failures once at each end of an outage instead of discarding them.
7. Clamp the acknowledged log index, a too-large ack panicked on a slice bound.
8. Stop reading server health from a `FetchTask` deadline, it marked the runner healthy and reset the error backoff on a timeout.
9. Return an error from the Docker version probe instead of a `logrus` panic.
10. Pass the context to go-git's fetch and pull.
11. Fail the clone when a refresh dies on a cancelled context.
12. Set `terminationGracePeriodSeconds` in the Kubernetes examples.

Also contains a deprecation fix for goreleaser.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1174
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-19 11:13:21 +00:00
silverwind
3f70822458 enhance: add runner.tool_cache_mode and default it to none (#1171)
The current shared tools cache is not concurrency-safe, e.g. multiple jobs can write and corrupt it, for example `setup-go` with explicit go version under concurrency reliably corrupts the tool cache and fails all jobs.

This adds a new `runner.tool_cache_mode` (and `--tool-cache-mode` exec option) option which defaults to unshared tools cache:

- `none` mounts nothing, so a job uses what its image ships there and discards what it installs
- `shared` keeps the single volume every job reuses, and warns when `runner.capacity` is above 1

Under `none` effective tool cache can only come from the image or host, which is the same as it is on GitHub Actions which ships many preinstalled tools in its fat VM images.

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1171
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-18 20:16:11 +00:00
bircni
be90c01468 feat: add size-based cache eviction (#1170)
The cache server retired entries 30 days after creation regardless of use, so a job that ran often enough to keep its cache warm still lost it on a fixed schedule. Nothing bounded the disk either.

Retention now counts from last access alone, and a repository over its limit sheds least recently accessed entries until it fits, enforced on commit as well as on the periodic sweep.

```yaml
cache:
  retention: 168h        # remove entries not accessed for seven days
  repo_size_limit: 10GB  # cap each repository
  size_limit: 0          # cap the whole cache, off by default
  sweep_interval: 1h     # minimum time between sweeps
```

Sizes accept `10GB`, `512mb`, `1TiB` or a plain byte count, binary either way. Leave a key out for its default; `0` turns a limit off, and `0s` does the same for `retention`. Whatever these allow, the cache also sheds entries to keep free space above `health_check.min_free_disk_space_mb` when health checks are enabled, so it cannot grow past the point where the runner stops accepting work.

Supporting fixes: serving an entry stamps its access time, so a find cannot hand a job a download URL for an entry the next eviction is about to remove; an entry larger than the limit is dropped on its own account rather than emptying its repository to make room; and a blob that cannot be unlinked keeps its row, so the next sweep retries instead of orphaning bytes no limit can account for.

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

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1170
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-08-18 15:32:53 +00:00
bircni
6c6a878403 enhance: show workflow_dispatch inputs in the "Set up job" section (#1167)
Trigger-time inputs used to be visible in the job log but are no longer shown after the "Set up job" section was reshaped. This restores them as an "Inputs" group listing each provided input and its value, rendered only when the event payload carries inputs.

For a run dispatched with `required=required input`, `with_default=default` and `boolean=true`, the "Set up job" log now shows:

​```
gitea-com-gitea-0003(version:v3.0.2)
▸ Runner Information
    Task: 268506
    Job: test
    Repository: gitea/runner
    Triggered by event: workflow_dispatch
▸ Inputs
    boolean: true
    required: required input
    with_default: default
▸ Operating System
    Ubuntu 24.04.4 LTS
    linux/amd64
​```

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/1167
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-08-15 19:57:30 +00:00
Lunny Xiao
f97680a68d chore: drop AWS S3 release upload, keep Cloudflare R2 only (#1169)
Release artifacts were uploaded to both AWS S3 and Cloudflare R2 during the migration period. Drop the goreleaser `blobs:` S3 pipe and the AWS_*/S3_* secrets from the release workflows, so artifacts are published to Cloudflare R2 only.

Assisted-by: Codet:GPT-5.1-Codex
Reviewed-on: https://gitea.com/gitea/runner/pulls/1169
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-08-15 19:41:04 +00:00
Renovate Bot
dbd9a892f8 chore(deps): update dependencies (#1160)
This PR contains the following updates:

| Package | Type | Update | Change | Pending | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|---|---|---|
| docker | stage | minor | `29.6.2-dind-rootless` → `29.7.1-dind-rootless` |  | ![age](https://developer.mend.io/api/mc/badges/age/docker/docker/29.7.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/docker/29.6.2/29.7.1?slim=true) |
| docker | stage | minor | `29.6.2-dind` → `29.7.1-dind` |  | ![age](https://developer.mend.io/api/mc/badges/age/docker/docker/29.7.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/docker/29.6.2/29.7.1?slim=true) |
| [github.com/docker/cli](https://github.com/docker/cli) | require | minor | `v29.6.2+incompatible` → `v29.7.1+incompatible` | `v29.7.2+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.7.1+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.6.2+incompatible/v29.7.1+incompatible?slim=true) |
| [github.com/moby/go-archive](https://github.com/moby/go-archive) | require | minor | `v0.2.1` → `v0.3.2` | `v0.3.3` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fmoby%2fgo-archive/v0.3.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fmoby%2fgo-archive/v0.2.1/v0.3.2?slim=true) |

---

### Release Notes

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

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

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

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

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

</details>

<details>
<summary>moby/go-archive (github.com/moby/go-archive)</summary>

### [`v0.3.2`](https://github.com/moby/go-archive/releases/tag/v0.3.2)

[Compare Source](https://github.com/moby/go-archive/compare/v0.3.1...v0.3.2)

#### What's Changed

Fix a regression introduced in v0.3.0 that caused archive extraction to fail when paths traversed absolute symlinks inside the destination root, such as `var/run -> /run`. Absolute symlink targets are now resolved relative to the extraction root while relative symlink escapes remain rejected. [#&#8203;93](https://github.com/moby/go-archive/pull/93)

**Full Changelog**: <https://github.com/moby/go-archive/compare/v0.3.1...v0.3.2>

### [`v0.3.1`](https://github.com/moby/go-archive/releases/tag/v0.3.1)

[Compare Source](https://github.com/moby/go-archive/compare/v0.3.0...v0.3.1)

#### Fixes

This patch release fixes a regression introduced in v0.2.1 where archive extraction could fail when an archive omitted explicit entries for parent directories. For example, extracting `etc/dnf/` without a preceding `etc/` entry could return `mkdirat etc/dnf: no such file or directory`.

This prevented affected images from being extracted. Archive extraction now creates implied parent directories for both file and directory entries.

#### What's Changed

- archive: create implied parents for directory entries [#&#8203;92](https://github.com/moby/go-archive/pull/92)
- archive: Tarballer.Go: suppress io.ErrClosedPipe logs on close [#&#8203;94](https://github.com/moby/go-archive/pull/94)

**Full Changelog**: <https://github.com/moby/go-archive/compare/v0.3.0...v0.3.1>

### [`v0.3.0`](https://github.com/moby/go-archive/releases/tag/v0.3.0)

[Compare Source](https://github.com/moby/go-archive/compare/v0.2.1...v0.3.0)

#### Security

This release fixes **CVE-2026-17106** / **[GHSA-hfg8-hc9c-6c3h](https://github.com/moby/go-archive/security/advisories/GHSA-hfg8-hc9c-6c3h)**, where a crafted tar archive could use links to cause extraction operations to create or overwrite files outside the intended destination directory.

The issue affected `Unpack`, `UnpackLayer`, `Untar`, `UntarUncompressed`, and the `ApplyLayer` helpers. Users should upgrade and avoid extracting untrusted archives with earlier versions.

#### What's Changed

- archive: harden tar extraction against path traversal [#&#8203;45](https://github.com/moby/go-archive/pull/45)
- archive: do not follow reparse points in chtimes [#&#8203;90](https://github.com/moby/go-archive/pull/90)
- archive: fix creation time updates on Windows [#&#8203;79](https://github.com/moby/go-archive/pull/79)
- archive: minor cleanups and godoc touch-up [#&#8203;87](https://github.com/moby/go-archive/pull/87)
- archive: RebaseArchiveEntries: fix archive path rebasing [#&#8203;43](https://github.com/moby/go-archive/pull/43)

#### Test and CI changes

- ci: enable dependabot for actions [#&#8203;81](https://github.com/moby/go-archive/pull/81)
- archive: make breakoutErr unwrap its cause [#&#8203;91](https://github.com/moby/go-archive/pull/91)
- archive: use filepath for filesystem paths in tests [#&#8203;80](https://github.com/moby/go-archive/pull/80)
- archive: use filepath for filesystem paths in tests [#&#8203;80](https://github.com/moby/go-archive/pull/80)

**Full Changelog**: <https://github.com/moby/go-archive/compare/v0.2.1...v0.3.0>

</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/1160
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-10 20:46:33 +00:00
Max P.
e178c03adc fix(cache): build job URLs on the address its runner registered (#1153)
The cache server built every URL it hands a job from its own listen address, so jobs whose runner reaches it through a reverse proxy were sent to the internal one. This covered the v1 `archiveLocation`, the v2 signed cache URLs and `ACTIONS_RESULTS_URL`.

Runners now register the address their jobs reach the server at, next to the instance URL they already send. The cache-server needs no configuration of its own, and runners that reach it differently each get their own correct address.

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

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1153
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Max P. <mail@0xMax42.io>
2026-08-08 07:05:38 +00:00
Renovate Bot
b66433e667 fix(deps): update module github.com/go-git/go-git/v5 to v5.19.2 [security] (#1156)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) | `v5.19.1` → `v5.19.2` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgo-git%2fgo-git%2fv5/v5.19.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgo-git%2fgo-git%2fv5/v5.19.1/v5.19.2?slim=true) |

---

### go-git: Worktree operations may follow symlinks
[CVE-2026-71556](https://nvd.nist.gov/vuln/detail/CVE-2026-71556) / [GHSA-hc8v-wwc9-vgxm](https://github.com/advisories/GHSA-hc8v-wwc9-vgxm)

<details>
<summary>More information</summary>

#### Details
##### Impact

A symlink traversal issue in `go-git` could allow worktree operations to modify files outside the intended worktree path.

The `worktreeFilesystem` wrapper rejected dangerous path strings, including paths containing `.git`, parent-directory components, or control characters. However, it did not prevent filesystem operations from following symbolic links that were already present in the worktree.

As a result, a path that is safe when evaluated as a string could still resolve into the repository's Git metadata directory. For example, if `s` is a symbolic link to `.git`, writing to `s/config` would modify `.git/config`.

A symbolic link at the final path component could also be followed. For example, if `s` points directly to `.git/config`, opening `s` for writing with truncation could overwrite the repository configuration.

Exploitation requires an attacker to be able to introduce or control a symbolic link in the worktree and cause the application to perform a write through that path.

Applications using `storage/memory` for their Storer, or `go-billy/memfs` for their `Worktree`, are not affected by this vulnerability.

##### Patches

The issue has been addressed by making the worktree filesystem wrapper a symlink-safe boundary.

Worktree operations now reject paths where an existing symbolic link in any path component could cause the operation to escape the intended worktree location, including symbolic links at the final component.

Users of filesystem-backed worktrees should upgrade to a patched version.

##### Credits

Thanks to @&#8203;kodareef5 for reporting this issue and working with the go-git security team toward its resolution. 🥇
We would also like to thank @&#8203;HughLewis20, who independently reported the same issue while a fix was already in progress.

#### Severity
- CVSS Score: 7.1 / 10 (High)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L`

#### References
- [https://github.com/go-git/go-git/security/advisories/GHSA-hc8v-wwc9-vgxm](https://github.com/go-git/go-git/security/advisories/GHSA-hc8v-wwc9-vgxm)
- [008a78f2dd)
- [661d1c7f10)
- [https://github.com/go-git/go-git](https://github.com/go-git/go-git)
- [https://github.com/go-git/go-git/releases/tag/v5.19.2](https://github.com/go-git/go-git/releases/tag/v5.19.2)
- [https://github.com/go-git/go-git/releases/tag/v6.0.0-alpha.5](https://github.com/go-git/go-git/releases/tag/v6.0.0-alpha.5)

This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-hc8v-wwc9-vgxm) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### go-git: Malicious reference names may modify files outside the reference storage
[CVE-2026-71557](https://nvd.nist.gov/vuln/detail/CVE-2026-71557) / [GHSA-qgq7-7hm3-q39j](https://github.com/advisories/GHSA-qgq7-7hm3-q39j)

<details>
<summary>More information</summary>

#### Details
##### Impact
A path traversal issue in `go-git` could allow malicious reference names to access files outside the repository's intended reference storage.

Loose references are stored under `.git/<reference-name>`. The reference name was previously used as a path without verifying that the resolved path remained within the reference storage. A name such as `refs/heads/../../config` could therefore resolve to unrelated repository metadata such as `.git/config` or `.git/HEAD`.

A malicious Git server could advertise such a reference name. The name may also survive refspec mapping; for example, it could be mapped to `refs/remotes/origin/../../config` during a clone or fetch operation.

This vulnerability affects filesystem-backed repositories using the `storage/filesystem` package and its `dotgit` reference storage. Users relying exclusively on the in-memory storage implementation, `storage/memory`, are not affected, because reference names are not resolved as filesystem paths.

Exploitation requires an application using `go-git` with filesystem-backed storage to interact with a malicious Git server or otherwise process attacker-controlled reference names.

##### Patches
The issue has been addressed by validating reference names at the `dotgit` storage entry points and rejecting names whose resolved paths could escape the reference storage.

Users of filesystem-backed storage should upgrade to a patched version.

##### Workarounds
Applications that exclusively use `storage/memory` are not affected and do not require a workaround for this vulnerability.

For applications using filesystem-backed storage, avoid cloning from or fetching from untrusted Git servers until an upgrade is possible.

Applications that directly construct or process reference names may also validate them before passing them to filesystem-backed `go-git` storage. Application-level validation should only be considered a temporary mitigation and does not replace upgrading to a patched version.

##### References
- Fixes:
  - https://github.com/go-git/go-git/pull/2247
  - https://github.com/go-git/go-git/pull/2254

##### Credits

Thanks to @&#8203;Saku0512 for reporting this issue and @&#8203;Sahana2524 for proposing the initial fix. 🙇

#### Severity
- CVSS Score: 6.3 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:H/A:L`

#### References
- [https://github.com/go-git/go-git/security/advisories/GHSA-qgq7-7hm3-q39j](https://github.com/go-git/go-git/security/advisories/GHSA-qgq7-7hm3-q39j)
- [https://github.com/go-git/go-git/pull/2247](https://github.com/go-git/go-git/pull/2247)
- [https://github.com/go-git/go-git/pull/2254](https://github.com/go-git/go-git/pull/2254)
- [4a0e66d555)
- [da9f7d8a0e)
- [https://github.com/go-git/go-git](https://github.com/go-git/go-git)
- [https://github.com/go-git/go-git/releases/tag/v5.19.2](https://github.com/go-git/go-git/releases/tag/v5.19.2)
- [https://github.com/go-git/go-git/releases/tag/v6.0.0-alpha.5](https://github.com/go-git/go-git/releases/tag/v6.0.0-alpha.5)

This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-qgq7-7hm3-q39j) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>go-git/go-git (github.com/go-git/go-git/v5)</summary>

### [`v5.19.2`](https://github.com/go-git/go-git/releases/tag/v5.19.2)

[Compare Source](https://github.com/go-git/go-git/compare/v5.19.1...v5.19.2)

#### What's Changed

- build: Update module golang.org/x/crypto to v0.52.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2150](https://github.com/go-git/go-git/pull/2150)
- build: Update module github.com/go-git/go-git/v5 to v5.19.1 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2141](https://github.com/go-git/go-git/pull/2141)
- build: Update module golang.org/x/net to v0.55.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2152](https://github.com/go-git/go-git/pull/2152)
- git: Worktree: Add stores index entires with backslashes on Windows by [@&#8203;joshblum](https://github.com/joshblum) in [#&#8203;2262](https://github.com/go-git/go-git/pull/2262)
- storage: dotgit, reject path traversal in reference names by [@&#8203;pjbgf](https://github.com/pjbgf) in [#&#8203;2254](https://github.com/go-git/go-git/pull/2254)
- build: Update module golang.org/x/net to v0.56.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2267](https://github.com/go-git/go-git/pull/2267)
- build: Update module golang.org/x/text to v0.39.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2268](https://github.com/go-git/go-git/pull/2268)
- \[v5] git: worktree, make the filesystem wrapper a symlink-safe boundary by [@&#8203;pjbgf](https://github.com/pjbgf) in [#&#8203;2277](https://github.com/go-git/go-git/pull/2277)

**Full Changelog**: <https://github.com/go-git/go-git/compare/v5.19.1...v5.19.2>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1156
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-08 00:35:53 +00:00
Lunny Xiao
a8dcd5b67c refactor: move act/model and act/exprparser to actionslib (#1143)
Gitea needs the workflow model and the expression evaluator to parse workflows and to build the task payload this runner consumes, so today it depends on `gitea.com/gitea/runner` just for `act/model` and `act/exprparser`. Both packages now live in `gitea.dev/actionslib` (`pkg/model`, `pkg/exprparser`), the module both sides already share, and this repository consumes them from there.

### Changes

- `act/model` and `act/exprparser` are deleted, all imports point at `gitea.dev/actionslib/pkg/...`.
- New `act/ghcontext` package: the `GithubContext` helpers that need a git checkout on disk (`SetRef`, `SetSha`, `SetRepositoryAndOwner`) are runner only and would drag a git client plus the act context logger into the shared module, so they stay here as functions, with their tests. Only caller is `RunContext.getGithubContext`.
- `act/common.CartesianProduct` moved to the shared model package, `act/model` was its only user.
- `act/model/testdata/container-volumes` moved to `act/runner/testdata/container-volumes`, its only user is `runner_test.go`.
- `internal/pkg/client.UUIDHeader` / `TokenHeader` now alias `pkg/protocol`, so the header names cannot drift apart from Gitea.

### Notes

- No behaviour change intended: the moved files are unchanged apart from the import paths and the split described above.
- `go.mod` depends on the released `gitea.dev/actionslib v0.7.0`, which carries both https://gitea.com/gitea/actionslib/pulls/11 and the `model.UsesHash` port in https://gitea.com/gitea/actionslib/pulls/14 that `main` needs after https://gitea.com/gitea/runner/pulls/1150.
- Verified with `go build ./...`, `go vet ./...` and `go test ./act/... ./internal/...`; the docker based `act/runner` integration tests (`TestRunEvent`, `TestRunMatrixWithUserDefinedInclusions`) fail identically with and without this change in my environment.

Assisted-by: Codet:GPT-5.1-Codex
Reviewed-on: https://gitea.com/gitea/runner/pulls/1143
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-08-08 00:29:15 +00:00
silverwind
da9b559fb5 chore: revert docker 29.7.0 workaround (#1155)
Revert https://gitea.com/gitea/runner/pulls/1130. Docker 29.7.1 fixed both regressions it worked around, https://github.com/moby/moby/pull/53261 and https://github.com/moby/moby/pull/53260, so only 29.7.0 still needs it.

Verified live with a relative and an absolute `/var/run` symlink: without the workaround the copy passes on 29.4.0, 29.6.2 and 29.7.1, and fails on 29.7.0 alone.

Fixes: https://gitea.com/gitea/runner/issues/1131
Reviewed-on: https://gitea.com/gitea/runner/pulls/1155
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-07 19:19:43 +00:00
silverwind
9dd9204937 feat: support $/ prefix in action uses: (#1150)
Accepts GitHub's `$/` self-repository prefix in a step `uses:`. It resolves to the repository holding the file that wrote the `uses:`, at the ref being run, with no checkout. Inside a composite action that is the enclosing action, otherwise the workflow's own repo and commit.

The action cache is keyed on the resolved reference for these, because the same `$/x` names a different action per enclosing repository.

Related PR for job-level support: https://github.com/go-gitea/gitea/pull/38822

Reviewed-on: https://gitea.com/gitea/runner/pulls/1150
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-07 17:36:58 +00:00
Lunny Xiao
24c13a1fd0 chore: revert 4c2ab943a8 (#1148)
Revert #1136 and use actionslib instead.

revert chore: bump the module path to `/v3`, take the version from the VCS stamp (#1136)

gitea can not consume the runner's api by version while it's version mismatches the module version:

```
go: gitea.com/gitea/runner@v3.0.1: invalid version: module contains a go.mod file,
so module path must match major version ("gitea.com/gitea/runner/v3")
```

Fix that by bumping the module version now. The existing `v3.0.0` and `v3.0.1` tags stay unusable, so a new tag is needed after this lands.

Also drop the version `-X` linker flags, which would otherwise have to repeat the new path in both `Makefile` and `.goreleaser.yaml`, where a stale path makes injection silently no-op. Go has recorded the module version in the build info since 1.24, so `Version()` reads it from there, keeping the variable as an override for builds without a VCS stamp.

That part started as https://gitea.com/gitea/runner/pulls/1137 but belongs here: the stamp resolves against the tags that are legal for the module path, so without the `/v3` bump it would report `v1.0.9-0.<ts>-<sha>`. Since `release-nightly.yml` triggers on every push to `main`, splitting them would publish a nightly with a `v1` version.

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1136
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1148
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-08-06 21:50:21 +00:00
silverwind
4c2ab943a8 chore: bump the module path to /v3, take the version from the VCS stamp (#1136)
gitea can not consume the runner's api by version while it's version mismatches the module version:

```
go: gitea.com/gitea/runner@v3.0.1: invalid version: module contains a go.mod file,
so module path must match major version ("gitea.com/gitea/runner/v3")
```

Fix that by bumping the module version now. The existing `v3.0.0` and `v3.0.1` tags stay unusable, so a new tag is needed after this lands.

Also drop the version `-X` linker flags, which would otherwise have to repeat the new path in both `Makefile` and `.goreleaser.yaml`, where a stale path makes injection silently no-op. Go has recorded the module version in the build info since 1.24, so `Version()` reads it from there, keeping the variable as an override for builds without a VCS stamp.

That part started as https://gitea.com/gitea/runner/pulls/1137 but belongs here: the stamp resolves against the tags that are legal for the module path, so without the `/v3` bump it would report `v1.0.9-0.<ts>-<sha>`. Since `release-nightly.yml` triggers on every push to `main`, splitting them would publish a nightly with a `v1` version.

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1136
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-06 20:05:42 +00:00
silverwind
20497aaf4f fix: evaluate each ${{ }} part on its own (#1146)
Every `${{ }}` part was spliced as raw text into a synthesized `format('...', <raw>)` call and re-parsed, so unbalanced parentheses restructured the whole expression:

```yaml
run: echo ${{ 1) && (2 }}      # panicked with "did not evaluate to a string"
if: ${{ 1 }} ${{ 0) && (0 }}   # silently skipped the step
```

One scanner shaped like GitHub's template reader now splits every value, and each part is evaluated on its own, so nothing builds an expression out of text.

An empty input to `Evaluate` asked `success()` whatever the caller requested, so a post step running under `always()` asked the wrong question.

Same fix as https://github.com/go-gitea/gitea/pull/38754 on the Gitea side.

Written by Claude Opus 5.

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1146
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-06 16:05:39 +00:00
silverwind
09b643bc14 fix: trim whitespace from register inputs (#1147)
Secrets often carry a trailing newline, for example from `echo "token" | base64`, which made `register --no-interactive` fail with `runner registration token not found`. The interactive path already trims typed values, this aligns the flag path.

Related to: https://gitea.com/gitea/runner/issues/727

Reviewed-on: https://gitea.com/gitea/runner/pulls/1147
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-06 15:50:16 +00:00
silverwind
1d6c6ffef9 enhance: improve config, comment out values in example file (#1145)
`config.example.yaml` now has every value commented out, as gitea's `app.example.ini` does, so it documents each option with its default instead of imposing it. Copying it no longer pins values the runner would otherwise pick, and a changed default reaches configs that never named the option.

`config init` writes the file to configure: a header comment and no option, so every option keeps its default. It refuses to overwrite an existing config without `--force`, and writes `config.yaml` in the working directory when `-c` is absent.

`config set`, `add` and `remove` keep such a file readable. Comments go back to the indentation they were written at, which the encoder drops for a comment block that has no key below it, and a file of only comments keeps its text instead of being emptied by the first edit.

Also rewrote the config docs sections for clarity.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1145
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-06 05:15:32 +00:00
silverwind
70387cca44 chore: align go version handling with gitea (#1144)
Aligns Go version handling with gitea, see https://github.com/go-gitea/gitea/pull/38559. `toolchain` names the build version, `go` stays the minimum.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1144
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-05 22:50:11 +00:00
bircni
68547886a5 feat: wait for healthy services and fill the job context (#1107)
Service containers were started and then left alone, so a job's first step could run while a database was still starting up. The runner now waits for every service whose image or `options` declare a healthcheck, as GitHub does. An unhealthy service fails the job with its container log, one that never becomes healthy fails it after `container.service_ready_timeout` (default `5m`, negative disables the wait), and one that exits without a healthcheck only gets its log and a warning.

The started containers also fill the `job` context, whose fields existed but were never populated: `job.container.{id,network}` and `job.services.<id>.{id,network,ports}`. `ports` is keyed by the plain container port, so `job.services.postgres.ports['5432']` resolves to the host port Docker picked.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1107
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-08-05 19:46:43 +00:00
bircni
8700adc933 feat: config command to edit config files (#1140)
Changing a setting after `generate-config` meant hand-editing YAML, which is awkward in provisioning scripts. `config` now edits an existing file in place:

```bash
./gitea-runner -c config.yaml config set runner.capacity 4
./gitea-runner -c config.yaml config set runner.envs.MY_VAR value
./gitea-runner -c config.yaml config add runner.labels 'ubuntu:docker://node:22'
./gitea-runner -c config.yaml config remove runner.labels 'ubuntu:docker://node:22'
./gitea-runner -c config.yaml config get runner.labels
```

Edits go through the YAML node tree, so comments, key order and the blank lines between top-level sections survive — a test asserts that appending a label to `config.example.yaml` changes nothing but the added line. Keys are resolved by reflecting over the `Config` struct's yaml tags, so an unknown key or a value of the wrong type is rejected before the file is touched. The write is atomic and keeps the file's symlink, owner, mode and line endings.

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1140
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-08-05 16:48:25 +00:00
bircni
b70ff6893a feat: gate set-env/add-path and render annotation locations (#1109)
`::set-env::` and `::add-path::` let a step rewrite the environment of every later step from its own output, which the runner honoured silently. They are now refused, as GitHub has done since 2020, and `ACTIONS_ALLOW_UNSECURE_COMMANDS` opts back in per step or job. Support for that variable is new here too, and is the only opt-in, matching GitHub rather than adding a runner config key on top.

Annotations keep their source location: Gitea has no annotation store and its web UI strips command properties, so `::error file=main.go,line=12::msg` is rendered as `::error::main.go:12: msg`.

`DEVELOPMENT.md` writes down the log line encoding rules this relies on.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1109
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-08-05 16:43:17 +00:00
silverwind
3618385b28 fix: serve the whole results service from the cache server (#1141)
`ACTIONS_RESULTS_URL` names one origin serving every `github.actions.results.api.v1` service. Gitea serves the artifact half and this runner the cache half, so announcing `ACTIONS_CACHE_SERVICE_V2` while that URL pointed at Gitea was a promise the environment could not keep, and `docker buildx` posted its cache calls at Gitea and got a 404.

The cache server now forwards the artifact half to the instance each job registers with, so it is the whole results service and jobs are pointed at it. The announcement follows, and the bundle patch follows the cache URL instead.

Also fixes three things no JavaScript client reached: camelCase in the v2 responses where the Go clients read proto names, the missing `x-ms-request-id` on blob uploads that panics buildkit, and `cache.external_server` passed through without the trailing slash the v1 client concatenates onto.

Tests run the real actions against the services they look for: `actions/cache` over both API versions, the artifact actions up and back down through the forwarding, and `setup-node`. The regression itself is covered by asserting that whatever a job is handed as `ACTIONS_RESULTS_URL` answers a cache service call.

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/1141
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-08-03 16:41:53 +00:00
Renovate Bot
55a625f733 chore(deps): update dependencies (#1138)
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-03 09:22:17 +00:00
281 changed files with 20897 additions and 13960 deletions

View File

@@ -20,11 +20,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
# Custom publishers (the R2 mirror below) run as the very last
# Custom publishers (the R2 upload below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release
# has already been created and every artifact already uploaded
# to S3. Fail here instead, before anything is built or
# published, if the R2 secrets are missing.
# has already been created. Fail here instead, before anything
# is built or published, if the R2 secrets are missing.
- name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config
env:
@@ -35,6 +34,7 @@ jobs:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: "go.mod"
check-latest: true
- name: goreleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
@@ -42,11 +42,6 @@ jobs:
args: release --nightly
env:
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
@@ -79,13 +74,13 @@ jobs:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -99,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

View File

@@ -12,11 +12,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # all history for all branches and tags
# Custom publishers (the R2 mirror below) run as the very last
# Custom publishers (the R2 upload below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release
# has already been created and every artifact already uploaded
# to S3. Fail here instead, before anything is built or
# published, if the R2 secrets are missing.
# has already been created. Fail here instead, before anything
# is built or published, if the R2 secrets are missing.
- name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config
env:
@@ -27,6 +26,7 @@ jobs:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: "go.mod"
check-latest: true
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
@@ -41,11 +41,6 @@ jobs:
args: release
env:
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
@@ -82,13 +77,13 @@ jobs:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -108,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

View File

@@ -1,35 +1,42 @@
name: checks
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
env:
# The runner image ships a stale docker.io login; point docker at an empty config so
# image pulls go straight to anonymous instead of attempting (and failing) that auth
# first. The path must be a literal: the `runner` context is unavailable in workflow-level
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
# to ~/.docker with the stale credentials.
DOCKER_CONFIG: /tmp/docker-noauth
jobs:
lint:
name: check and test
runs-on: ubuntu-latest
env:
# The runner image ships a stale docker.io login; point docker at an empty config so
# image pulls go straight to anonymous instead of attempting (and failing) that auth
# first. The path must be a literal: the `runner` context is unavailable in job-level
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
# to ~/.docker with the stale credentials.
DOCKER_CONFIG: /tmp/docker-noauth
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: 'go.mod'
check-latest: true
- name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
# 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:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # renovate: datasource=docker
run: |
for img in node:24-bookworm-slim nginx:alpine; do
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
for attempt in 1 2 3; do
docker pull "$image" && break
[ "$attempt" = 3 ] || sleep 5
done
docker tag "$image" "${image%@*}"
done
- name: lint
run: make lint
@@ -44,7 +51,28 @@ 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
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
e2e:
name: gitea compatibility (${{ matrix.gitea_image }})
runs-on: ubuntu-latest
strategy:
matrix:
gitea_image:
- gitea/gitea:latest
- gitea/gitea:main-nightly
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: 'go.mod'
check-latest: true
- name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
- name: e2e compatibility
run: make test-e2e E2E_GITEA_IMAGE=${{ matrix.gitea_image }}

View File

@@ -46,8 +46,7 @@ linters:
gocritic:
enabled-checks:
- equalFold
disabled-checks:
- ifElseChain
disabled-checks: []
revive:
severity: error
rules:
@@ -71,10 +70,14 @@ linters:
- name: unexported-return
- name: var-declaration
- name: var-naming
arguments:
- [] # AllowList - do not remove as args for the rule are positional and won't work without lists first
- [] # DenyList
- - skip-initialism-name-checks: true
staticcheck:
checks:
- all
- -ST1005
testifylint: {}
usetesting:
os-temp-dir: true
perfsprint:
@@ -92,8 +95,6 @@ linters:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
@@ -118,7 +119,8 @@ formatters:
- blank
- default
gofumpt:
extra-rules: true
extra:
group-params: true
exclusions:
generated: lax
run:

View File

@@ -83,24 +83,12 @@ builds:
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
blobs:
-
provider: s3
bucket: "{{ .Env.S3_BUCKET }}"
region: "{{ .Env.S3_REGION }}"
directory: "gitea-runner/{{.Version}}"
extra_files:
- glob: ./**.xz
- glob: ./**.sha256
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
# parallel S3+R2 period (S3 will be removed once migration completes).
# A second `blobs:` entry is impossible here since the blob pipe
# authenticates from the global AWS_* env with no per-entry
# credentials; `publishers:` supports per-entry `env:` instead, so
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
# publishers inherit almost nothing from the environment, hence the
# explicit R2_* forwarding below.
# Uploads every release artifact to Cloudflare R2. The `blobs:` pipe
# isn't usable here since it authenticates from the global AWS_* env
# with no per-entry credentials; `publishers:` supports per-entry
# `env:` instead, so it's used to invoke scripts/upload-r2.sh once per
# artifact. Custom publishers inherit almost nothing from the
# environment, hence the explicit R2_* forwarding below.
#
# This publisher fires 109 times for 73 distinct keys because
# goreleaser's release pipe already registers `release.extra_files`
@@ -125,7 +113,7 @@ publishers:
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
archives:
- format: binary
- formats: [binary]
name_template: "{{ .Binary }}"
allow_different_binary_count: true

View File

@@ -8,6 +8,7 @@
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
- Add the current year into the copyright header of new `.go` files
- Read `DEVELOPMENT.md` for internals and conventions
- Ensure no trailing whitespace in edited files
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, and `make checks` for the non-lint source checks
- Lint what changed with `make lint-go`, and `make lint-go-windows` for Windows and platform-split files

View File

@@ -1 +0,0 @@
@AGENTS.md

46
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,46 @@
# Development
## Job log line format
Gitea stores one log row per line and its web UI decodes the payload, so getting the encoding
wrong never fails a test here, it only shows up in the browser.
**A row cannot contain a real newline.** `FormatLog` rewrites `\n` to a literal backslash-n and
truncates at 64 KiB on a byte boundary.
**The payload of a line starting with a recognised prefix is decoded**, with the escape set
depending on the prefix:
| prefix | decodes |
| --- | --- |
| `##[error]` `##[warning]` `##[notice]` `##[debug]` `##[group]` `##[endgroup]` `##[add-matcher]` | `%25` `%0D` `%0A` `%3B` `%5D` |
| `::error::` `::warning::` `::notice::` `::debug::` (with or without ` key=value` properties), `::group::` `::endgroup::` `::add-matcher::` | `%25` `%0D` `%0A` |
| `##[command]` `[command]`, or no recognised prefix | nothing |
### Rules
- **Emitting a command line?** Escape the payload with `runner.EscapeCommandData`. One escaper
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
- **Forwarding a command from step output?** Leave the payload alone, it arrived escaped and is
decoded once. Decoding here double-decodes and destroys multi-line.
- **No prefix?** Do not escape, and split multi-line values into one row each.
- **Interpolating a secret?** Masking runs after escaping, so `AppendSecretMasker` registers the
encoded forms too.
- 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.
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
parallel.
The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.

View File

@@ -1,7 +1,7 @@
### BUILDER STAGE
#
#
FROM golang:1.26-alpine3.23 AS builder
FROM golang:1.27-alpine3.23 AS builder
# Do not remove `git` here, it is required for getting runner version when executing `make build`
RUN apk add --no-cache make git
@@ -17,7 +17,7 @@ RUN make clean && make build
### DIND VARIANT
#
#
FROM docker:29.6.2-dind AS dind
FROM docker:29.7.2-dind AS dind
ARG VERSION=dev
@@ -32,12 +32,14 @@ COPY scripts/s6 /etc/s6
VOLUME /data
ENV TINI_SUBREAPER=1
ENTRYPOINT ["s6-svscan","/etc/s6"]
### DIND-ROOTLESS VARIANT
#
#
FROM docker:29.6.2-dind-rootless AS dind-rootless
FROM docker:29.7.2-dind-rootless AS dind-rootless
ARG VERSION=dev
@@ -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"]

View File

@@ -5,8 +5,14 @@ GO ?= go
SHASUM ?= shasum -a 256
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
XGO_VERSION := go-1.26.x
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
XGO_VERSION := go-1.27.x
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:0e62e56b382ebf485bff1e51a05cbe27c708545a2e91425467fd31eb3e249521 # 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
@@ -18,8 +24,8 @@ DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
@@ -96,13 +102,8 @@ go-check:
fi
.PHONY: fmt-check
fmt-check: fmt
@diff=$$(git diff --color=always -- '*.go'); \
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
fmt-check: ## check the Go code formatting
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
.PHONY: deps-tools
deps-tools: ## install tool dependencies
@@ -137,20 +138,20 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
.PHONY: security-check
security-check:
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
$(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
.PHONY: tidy
tidy: ## run go mod tidy
$(eval GO_TOOLCHAIN := $(shell grep -Eo '^toolchain\s+go[0-9.]+' go.mod | cut -d' ' -f2))
$(GO) mod tidy
@# workaround https://github.com/golang/go/issues/75331: restore toolchain if tidy dropped it
@if [ -n "$(GO_TOOLCHAIN)" ] && ! grep -qE '^toolchain\s' go.mod; then \
$(GO) mod edit -toolchain=$(GO_TOOLCHAIN); \
fi
.PHONY: tidy-check
tidy-check: tidy
@diff=$$(git diff --color=always -- go.mod go.sum); \
if [ -n "$$diff" ]; then \
echo "Please run 'make tidy' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
tidy-check: ## check that go.mod and go.sum are tidy
$(GO) mod tidy -diff
.PHONY: test
test: ## test everything (integration tests self-skip without docker/network)
@@ -163,8 +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_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_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`

269
README.md
View File

@@ -4,7 +4,19 @@
### Prerequisites
Docker Engine Community version is required for docker mode. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
On the runner:
- `git` 2.34.1 or newer on `PATH`, to download actions and reusable workflows.
- `docker` or `podman` with `podman.socket` enabled, for docker mode, Docker actions and `docker://` steps. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
Where steps run, in the job image or on the runner in host mode:
- `bash` or `sh` for `run` steps, `pwsh` or `powershell` on a Windows host.
- `node` for JavaScript actions and `hashFiles()`.
- `git` 2.34.1 or newer for [`builtin:checkout`](docs/builtin-actions.md).
- `git-lfs` and `ssh` for `builtin:checkout`, only when its `lfs` or `ssh-key` input is used.
Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images.
### Download pre-built binary
@@ -79,77 +91,85 @@ 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
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
The runner reads a YAML file. Without one, every option keeps its default.
```bash
./gitea-runner generate-config > config.yaml
./gitea-runner config init # write config.yaml, with no option set
./gitea-runner config generate | less # read what the options do
./gitea-runner -c config.yaml daemon # -c also works on register and cache-server
```
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`):
`config generate` prints [config.example.yaml](internal/pkg/config/config.example.yaml). Every value in it is commented out, so copy the lines you want to change into your own file and uncomment them.
#### Editing a config file
`config` edits a file in place, which is handy in provisioning scripts:
```bash
./gitea-runner -c config.yaml register
./gitea-runner -c config.yaml daemon
./gitea-runner -c config.yaml cache-server
./gitea-runner config set runner.capacity 4
./gitea-runner config set runner.timeout 90m # written as 1h30m0s
./gitea-runner config set runner.envs.MY_VAR value
./gitea-runner config add runner.labels 'ubuntu:docker://node:22'
./gitea-runner config remove runner.labels 'ubuntu:docker://node:22'
./gitea-runner config get runner.labels
```
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `generate-config` prints).
A key is its dotted YAML path. An unknown key, a value of the wrong type, or `add`/`remove` on anything but a list is refused before the file is touched. `set` replaces a whole list when you give it several values.
#### Without a config file
An edit keeps the comments and the key order of the file. Indentation becomes two spaces, and a blank line between two values is dropped.
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
`config get`, `set`, `add` and `remove` use `config.yaml` (or `config.yml`) from the working directory, then from the directory of the binary, and print their choice to stderr. `config init` writes `config.yaml` in the working directory, and refuses to overwrite an existing config without `--force`. Pass `-c` for another path.
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
#### Tool cache
Setup actions like `setup-go` install tools into `RUNNER_TOOL_CACHE`, which is `/opt/hostedtoolcache` inside a job. `runner.tool_cache_mode` selects what backs it:
| Mode | Tool cache | Trade-off |
| --- | --- | --- |
| `none` (default) | Per job, provided by the job image | A version the image lacks is downloaded in every job |
| `shared` | One volume reused by every job | Two jobs writing the same tool version at once corrupt it, so use it only with `runner.capacity: 1` |
With `none`, tools must come from the job image. Install them into `/opt/hostedtoolcache/<tool>/<version>/<arch>`, with an empty `<arch>.complete` file next to the directory:
```dockerfile
RUN GO=$(curl -fsSL 'https://go.dev/dl/?mode=json' | grep -oP '"version": "\Kgo1\.26\.[0-9]*' | head -1); \
DIR="/opt/hostedtoolcache/go/${GO#go}/x64" && \
mkdir -p "$(dirname "$DIR")" && \
curl -fsSL "https://dl.google.com/go/${GO}.linux-amd64.tar.gz" | tar -xz -C /tmp && \
mv /tmp/go "$DIR" && \
touch "${DIR}.complete"
```
A workflow requesting a minor version, `go-version: "1.26"`, resolves to the newest matching version in the cache, so a patch update in the image still hits it.
Of the [runner images](https://gitea.com/gitea/runner-images), the `-full` flavour is the one that ships tools in this layout.
`gitea-runner exec` reads no config file and takes `--tool-cache-mode` instead, defaulting to `none`.
#### Environment variables
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. Config values can read the runner's environment as `${NAME}`, for example `capacity: ${RUNNER_CAPACITY}`. An unset `NAME` fails the load, and `$${NAME}` is a literal `${NAME}`. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
### Labels
@@ -164,10 +184,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>`:
@@ -175,6 +195,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
@@ -189,9 +215,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, the job still runs, in the default `docker.gitea.com/runner-images:ubuntu-latest` image. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
Jobs run on `runner.default_image` when no runner label matches their `runs-on`: as a pod on runners with `kubernetes` labels, otherwise in a container, or on the host when docker is unavailable. Suitable images 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.
@@ -209,6 +235,37 @@ Whenever the resulting labels differ from the ones in the registration file, the
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
#### Service containers
A job's `services` are started before its steps run. When a service's image or its `options` declare a healthcheck, the runner waits for it to report healthy, so a workflow does not have to poll for its own services:
```yaml
services:
postgres:
image: postgres:17
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-retries 10
```
A service that reports unhealthy fails the job right away, with its container log. One that never becomes healthy fails it after `container.service_ready_timeout` (default `5m`, negative disables the wait). A service that exits without declaring a healthcheck only gets its log and a warning.
A job in a container reaches a service by its id on the job network, on the port the service listens on, for example `psql -h postgres -p 5432`. The started containers also fill the `job` context: `job.container.{id,network}` and `job.services.<id>.{id,network,ports}`, where `ports` maps a container port to the host port Docker published it on, for the services that publish one.
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
Containers a job starts through the Docker socket can bind-mount the workspace by the job's own path, as on a host, for example `docker run -v "$PWD:/src"`. Without the Docker proxy, for example with a remote `DOCKER_HOST`, use `GITEA_DOCKER_WORKSPACE`, the path the daemon sees, as the prefix of workspace binds, with `.` as the fallback for local use:
```yaml
volumes:
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
```
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. Host jobs use their existing Docker access, where `.` works. The proxy removes the containers, networks and volumes a job creates, `docker compose` services included, after post steps and the completed hook. Set `container.sweep: false` when jobs deploy services to the runner's Docker daemon.
#### Proxy
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
@@ -223,14 +280,14 @@ The runner uses them for its own requests and gives them to every job, in lower
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
- the cache server
- the built-in cache server, whose address is assigned at startup
- `localhost`, `127.0.0.1` and `::1`
- the job's service containers
- the Docker daemon, when it is reached over `tcp://`
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.
@@ -238,55 +295,68 @@ 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 automatically. Cache entries are local to that runner — runners do not share a cache by default.
Each runner starts its own cache server, so runners do not share cached entries. A runner in a container on the jobs' Docker daemon attaches itself to each job network, so jobs reach its cache without extra setup.
**Cache service v2**
**Sharing a cache between runners**
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
Run one `gitea-runner cache-server` and point every runner at it, using the same secret everywhere, for example from `openssl rand -hex 32`:
```yaml
# cache-server.yaml
cache:
dir: /data/actcache
port: 8088
external_secret: "<secret>" # or external_secret_file: /path/to/secret
```
```bash
gitea-runner -c cache-server.yaml cache-server
```
```yaml
# each runner's config
cache:
external_server: "http://<cache-server-host>:8088/"
external_secret: "<secret>"
```
`--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:
v2: false
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"
```
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle and keeps the untouched copy beside it; a bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork.
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.
**Shared cache across multiple runners**
**Eviction**
Run one dedicated `gitea-runner cache-server` that all runners point at.
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.
1. Create a config file for the cache server host:
**Cache service v2**
```yaml
cache:
dir: /data/actcache
port: 8088
external_secret: "replace-with-a-strong-random-secret"
# external_secret_file: /path/to/secret # secret can also be passed via a file
```
`actions/cache` v3.4.0, v4.2.0 and later use the cache service v2 API, which the runner serves by default. These actions fall back to v1 on hosts they do not recognize as GitHub, so the runner removes that check from them while a job runs. This also lets the stock `actions/upload-artifact` v4.4.0 and `actions/download-artifact` v4.1.8 and later work without the `gitea-upload-artifact` fork. With `runner.patch_actions: false`, the cache stays on v1 and the artifact actions fail.
2. Start the server:
```bash
gitea-runner -c cache-server-config.yaml cache-server
```
3. On every runner:
```yaml
cache:
external_server: "http://<cache-server-host>:8088/"
external_secret: "replace-with-a-strong-random-secret" # must match the server
# external_secret_file: /path/to/secret # secret can also be passed via a file
```
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
With v2, artifact calls also go through the cache server, so jobs must be able to reach it. `cache.v2: false` sends them to Gitea directly, unless the Gitea URL has a path or `runner.insecure` is set with HTTPS.
#### Official Docker image
@@ -299,6 +369,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`)
@@ -321,6 +392,20 @@ 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.
#### Secret masking
A job's secrets and its `::add-mask::` values are hidden from what the runner writes and uploads: the job log, the local copy above, job summaries, and the names of the containers it creates. A job output carrying one is skipped with a warning rather than sent masked, as GitHub does, so a downstream `needs.<job>.outputs.<name>` reading it is empty.
`log.job.retention` (default `168h`) is how long a log is kept, expired ones being deleted as new tasks start, and `log.job.max_size` (default `1GB`) caps one log. Keep `retention` above `runner.timeout` so a long job cannot outlive its own log, and prefer local disk, the file is written while the job runs. Only the runner's own user can read it.
### Example Deployments
Check out the [examples](examples) directory for sample deployment types.

View File

@@ -5,21 +5,24 @@
package artifactcache
import (
"cmp"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
@@ -27,6 +30,7 @@ import (
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/internal/pkg/disk"
"github.com/julienschmidt/httprouter"
"github.com/sirupsen/logrus"
@@ -52,7 +56,15 @@ type credKey struct{}
// poison another repo's cache, even from inside a container that reaches the
// cache server over the docker bridge network.
type JobCredential struct {
Repo string
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.
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"`
}
// credEntry holds a registered job's credential along with an active
@@ -67,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
@@ -77,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
@@ -94,19 +110,40 @@ type Handler struct {
credMu sync.RWMutex
creds map[string]*credEntry
policy Policy
// freeDisk is a field so tests can drive evictForFreeSpace without a full volume.
freeDisk func(string) (uint64, error)
}
// StartHandler opens the on-disk cache store and starts the HTTP server.
//
// internalSecret, when non-empty, enables a control-plane API at
// /_internal/{register,revoke} that lets a remote runner pre-register the
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
// embedded in-process handler leaves it empty and registers tokens via the
// in-process RegisterJob method directly.
func StartHandler(dir, outboundIP string, port uint16, internalSecret string, logger logrus.FieldLogger) (*Handler, error) {
// Options configures a cache server started by StartHandler; the zero value is usable.
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
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
// embedded in-process handler leaves it empty and registers tokens via the
// in-process RegisterJob method directly.
InternalSecret string
Policy Policy
Logger logrus.FieldLogger
}
// 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{
creds: make(map[string]*credEntry),
internalSecret: internalSecret,
internalSecret: opts.InternalSecret,
policy: opts.Policy.withDefaults(),
freeDisk: disk.FreeBytes,
}
if logger == nil {
@@ -117,6 +154,25 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
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 {
@@ -135,13 +191,11 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
return nil, err
}
h.storage = storage
if outboundIP != "" {
h.outboundIP = 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)
@@ -165,11 +219,24 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
h.registerV2Routes(router)
router.NotFound = http.HandlerFunc(h.forwardOrNotFound)
h.router = router
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
@@ -177,32 +244,67 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
// by the bearer middleware and per-repo scoping, not by reachability.
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 {
// TODO: make the external url configurable if necessary
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
}
func (h *Handler) baseURL(cred JobCredential) string {
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
return base
}
return h.ExternalURL()
}
// RegisterJob makes token a valid bearer credential for cache requests from
@@ -211,10 +313,11 @@ func (h *Handler) ExternalURL() string {
// is only accepted while the job is running.
//
// Registrations are reference-counted: if a token is already registered, the
// existing repo is kept and the refcount is incremented. The entry is
// removed only when every revoker returned by RegisterJob has been called.
// credential it was registered with is kept and the refcount is incremented.
// The entry is removed only when every revoker returned by RegisterJob has
// been called.
// This keeps a stray re-registration from silently revoking a live job.
func (h *Handler) RegisterJob(token, repo string) func() {
func (h *Handler) RegisterJob(token string, cred JobCredential) func() {
if h == nil || token == "" {
return func() {}
}
@@ -223,7 +326,7 @@ func (h *Handler) RegisterJob(token, repo string) func() {
existing.refs++
} else {
h.creds[token] = &credEntry{
cred: JobCredential{Repo: repo},
cred: cred,
refs: 1,
}
}
@@ -294,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()
@@ -317,8 +424,8 @@ func (h *Handler) Close() error {
func (h *Handler) openDB() (*bolthold.Store, error) {
return bolthold.Open(filepath.Join(h.dir, "bolt.db"), 0o644, &bolthold.Options{
Encoder: json.Marshal,
Decoder: json.Unmarshal,
Encoder: func(value any) ([]byte, error) { return json.Marshal(value) },
Decoder: func(data []byte, value any) error { return json.Unmarshal(data, value) },
Options: &bbolt.Options{
Timeout: 5 * time.Second,
NoGrowSync: bbolt.DefaultOptions.NoGrowSync,
@@ -327,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())
@@ -351,7 +524,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
}
h.responseJSON(w, r, 200, map[string]any{
"result": "hit",
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
"archiveLocation": h.signedArtifactURL(cred, cache.ID, time.Now().Add(artifactURLTTL)),
"cacheKey": cache.Key,
})
}
@@ -369,9 +542,12 @@ 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
// this call and the GET that follows it.
h.touch(db, cache)
return cache, nil
}
@@ -379,13 +555,15 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
api := &Request{}
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
if err := json.UnmarshalRead(r.Body, api); err != nil {
h.responseJSON(w, r, 400, err)
return
}
cache := api.ToCache()
cache.Repo = cred.Repo
cache := &Cache{Repo: cred.Repo, Key: api.Key, Version: api.Version, Size: api.Size}
if cache.Size == 0 {
cache.Size = -1
}
db, err := h.openDB()
if err != nil {
h.responseJSON(w, r, 500, err)
@@ -396,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
}
@@ -509,13 +687,31 @@ func (h *Handler) commitCache(cache *Cache) error {
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
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 {
return err
}
defer db.Close()
return db.Update(cache.ID, cache)
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)
h.evictTotal(db)
h.evictForFreeSpace(db)
return nil
}
// GET /_apis/artifactcache/artifacts/:id
@@ -549,12 +745,12 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
token := bearerToken(r)
if token == "" {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
h.unauthorized(w, r, errors.New("missing bearer token"))
return
}
cred, ok := h.lookupCredential(token)
if !ok {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
h.unauthorized(w, r, errors.New("unknown bearer token"))
return
}
ctx := context.WithValue(r.Context(), credKey{}, cred)
@@ -563,6 +759,14 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
}
}
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
h.twirpError(w, r, twirpUnauthenticated, err)
return
}
h.responseJSON(w, r, http.StatusUnauthorized, err)
}
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
// handler hands out, so one cannot be replayed as another; see computeSignature.
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
@@ -619,7 +823,7 @@ func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
type internalRegisterBody struct {
Token string `json:"token"`
Repo string `json:"repo"`
JobCredential
}
type internalRevokeBody struct {
@@ -627,9 +831,18 @@ type internalRevokeBody struct {
}
// POST /_internal/register
// ResultsURL is what a job registered with cred should be given as ACTIONS_RESULTS_URL, or "" when
// the credential names no instance to forward the artifact half to.
func (h *Handler) ResultsURL(cred JobCredential) string {
if h == nil || cred.Results == "" {
return ""
}
return h.baseURL(cred)
}
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var body internalRegisterBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := json.UnmarshalRead(r.Body, &body); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
@@ -637,14 +850,14 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
return
}
h.RegisterJob(body.Token, body.Repo)
h.RegisterJob(body.Token, body.JobCredential)
h.responseJSON(w, r, http.StatusOK)
}
// POST /_internal/revoke
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var body internalRevokeBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := json.UnmarshalRead(r.Body, &body); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
@@ -656,6 +869,12 @@ func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httpr
h.responseJSON(w, r, http.StatusOK)
}
// hashedToken fingerprints a job's bearer, so a reservation can tell its own retry from another job.
func hashedToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func bearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
@@ -682,16 +901,16 @@ func (h *Handler) computeSignature(purpose string, cacheID, exp int64) string {
}
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
func (h *Handler) signedURL(path, purpose string, cacheID uint64, exp time.Time) string {
func (h *Handler) signedURL(cred JobCredential, path, purpose string, cacheID uint64, exp time.Time) string {
expUnix := exp.Unix()
q := url.Values{}
q.Set("exp", strconv.FormatInt(expUnix, 10))
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
return fmt.Sprintf("%s%s/%d?%s", h.ExternalURL(), path, cacheID, q.Encode())
return fmt.Sprintf("%s%s/%d?%s", h.baseURL(cred), path, cacheID, q.Encode())
}
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
return h.signedURL(apiPath+"/artifacts", "", cacheID, exp)
func (h *Handler) signedArtifactURL(cred JobCredential, cacheID uint64, exp time.Time) string {
return h.signedURL(cred, apiPath+"/artifacts", "", cacheID, exp)
}
// if not found, return (nil, nil) instead of an error.
@@ -706,14 +925,9 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
if exact != nil {
return exact, nil
}
prefixPattern := "^" + regexp.QuoteMeta(prefix)
re, err := regexp.Compile(prefixPattern)
if err != nil {
continue
}
if err := db.FindOne(cache,
bolthold.Where("Repo").Eq(repo).
And("Key").RegExp(re).
bolthold.Where("Repo").Eq(repo).Index("Repo").
And("Key").MatchFunc(func(key string) (bool, error) { return strings.HasPrefix(key, prefix), nil }).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err != nil {
@@ -741,8 +955,8 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
}
cache := &Cache{}
err := db.FindOne(cache,
bolthold.Where("Repo").Eq(repo).
And("Key").Eq(key).
bolthold.Where("Key").Eq(key).Index("Key").
And("Repo").Eq(repo).
And("Version").Eq(version).
And("Complete").Eq(complete).
SortBy(sortBy).Reverse())
@@ -755,15 +969,23 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
return cache, nil
}
func insertCache(db *bolthold.Store, cache *Cache) error {
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
return fmt.Errorf("insert cache: %w", err)
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)
}
// write back id to db
if err := db.Update(cache.ID, cache); err != nil {
return fmt.Errorf("write back id to db: %w", err)
}
return nil
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)
}
// write back id to db
if err := db.TxUpdate(tx, cache.ID, cache); err != nil {
return fmt.Errorf("write back id to db: %w", err)
}
return nil
})
}
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
@@ -788,17 +1010,55 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
if requireIncomplete && cache.Complete {
return fmt.Errorf("cache %d: already complete", id)
}
if !touchNeeded(cache) {
return nil
}
cache.UsedAt = time.Now().Unix()
return db.Update(cache.ID, cache)
}
const (
keepUsed = 30 * 24 * time.Hour
keepUnused = 7 * 24 * time.Hour
keepTemp = 5 * time.Minute
keepOld = 5 * time.Minute
miB = 1024 * 1024
defaultSweepInterval = time.Hour
touchStale = time.Minute // how stale a completed entry's UsedAt may get before an access rewrites it, sparing hits the fsync
// inUseGrace covers artifactURLTTL plus the touchStale lag so an entry outlives every signed URL
// still usable for it, and no sweep cuts off a download in progress.
inUseGrace = artifactURLTTL + touchStale
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize
// a stale reservation.
uploadStallTimeout = 5 * time.Minute
metadataSyncInterval = 30 * time.Second
defaultMinFreeDisk = 1024 * miB
)
// Policy bounds what the cache server keeps: a retention window counted from last access,
// and size limits that evict least recently accessed first. A zero limit is no limit.
type Policy struct {
Retention time.Duration // Retention removes entries nothing has read or written within this window. Zero keeps them regardless of age.
RepoSizeLimit int64 // RepoSizeLimit caps one repository's completed entries in bytes, evicting least recently accessed first.
SizeLimit int64 // SizeLimit caps every repository's completed entries together, in bytes.
SweepInterval time.Duration // SweepInterval is the minimum time between two eviction sweeps.
MinFreeDisk int64 // MinFreeDisk is volume headroom the cache will not eat into. Tracks the runner's health-check floor rather than taking a key of its own.
}
func (p Policy) withDefaults() Policy {
// The limits default in config.LoadDefault, so a written 0 means off.
if p.MinFreeDisk <= 0 {
p.MinFreeDisk = defaultMinFreeDisk
}
if p.SweepInterval <= 0 {
p.SweepInterval = defaultSweepInterval
}
return p
}
func (h *Handler) gcCache() {
if h.gcing.Load() {
return
@@ -808,7 +1068,7 @@ func (h *Handler) gcCache() {
}
defer h.gcing.Store(false)
if time.Since(h.gcAt) < time.Hour {
if time.Since(h.gcAt) < h.policy.SweepInterval {
h.logger.Debugf("skip gc: %v", h.gcAt.String())
return
}
@@ -821,95 +1081,223 @@ func (h *Handler) gcCache() {
}
defer db.Close()
// Remove the caches which are not completed for a while, they are most likely to be broken.
var caches []*Cache
if err := db.Find(&caches, bolthold.
Where("UsedAt").Lt(time.Now().Add(-keepTemp).Unix()).
And("Complete").Eq(false),
); err != nil {
h.logger.Warnf("find caches: %v", err)
} else {
for _, cache := range caches {
h.storage.Remove(cache.ID)
if err := db.Delete(cache.ID, cache); err != nil {
h.logger.Warnf("delete cache: %v", err)
continue
}
h.logger.Infof("deleted cache: %+v", cache)
}
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)
h.evictForFreeSpace(db)
}
// evictForFreeSpace bounds the volume itself, so it also covers bytes the cache never
// accounted for.
func (h *Handler) evictForFreeSpace(db *bolthold.Store) {
free, err := h.freeDisk(h.dir)
if err != nil {
h.logger.Debugf("free disk check: %v", err) // unsupported platform, treat as unavailable rather than full
return
}
if free >= uint64(h.policy.MinFreeDisk) {
return
}
// Remove the old caches which have not been used recently.
caches = caches[:0]
if err := db.Find(&caches, bolthold.
Where("UsedAt").Lt(time.Now().Add(-keepUnused).Unix()),
); err != nil {
h.logger.Warnf("find caches: %v", err)
} else {
for _, cache := range caches {
h.storage.Remove(cache.ID)
if err := db.Delete(cache.ID, cache); err != nil {
h.logger.Warnf("delete cache: %v", err)
continue
}
h.logger.Infof("deleted cache: %+v", cache)
}
caches := h.completedByUse(db)
total, shortfall := totalSize(caches), h.policy.MinFreeDisk-int64(free)
if total <= shortfall {
// Say so, or shedding everything and still being short reads as the backstop working.
h.logger.Warnf("cache volume is %d MiB short of the free space floor with only %d MiB of cache on it; something else is filling it", shortfall/miB, total/miB)
}
h.evictTo(db, caches, total-shortfall, "the cache volume")
}
// Remove the old caches which are too old.
caches = caches[:0]
if err := db.Find(&caches, bolthold.
Where("CreatedAt").Lt(time.Now().Add(-keepUsed).Unix()),
); err != nil {
h.logger.Warnf("find caches: %v", err)
} else {
for _, cache := range caches {
h.storage.Remove(cache.ID)
if err := db.Delete(cache.ID, cache); err != nil {
h.logger.Warnf("delete cache: %v", err)
continue
}
h.logger.Infof("deleted cache: %+v", cache)
}
// evictIncomplete removes uploads that stopped part way, which are most likely broken.
func (h *Handler) evictIncomplete(db *bolthold.Store) {
h.sweep(db, bolthold.
Where("UsedAt").Lt(time.Now().Add(-uploadStallTimeout).Unix()).
And("Complete").Eq(false).
Index("UsedAt"))
}
func (h *Handler) evictExpired(db *bolthold.Store) {
if h.policy.Retention <= 0 {
return
}
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
window := max(h.policy.Retention+touchStale, inUseGrace)
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
}
// Remove the old caches with the same key and version within the same
// repository, keep the latest one. Aggregation must include Repo so two
// repos that happen to share a (key, version) do not evict each other —
// otherwise per-repo scoping holds for reads but one repo can age
// another out after keepOld.
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
if results, err := db.FindAggregate(
&Cache{},
bolthold.Where("Complete").Eq(true),
"Repo", "Key", "Version",
); err != nil {
// evictSuperseded removes entries a newer one with the same key and version replaced. The
// aggregation includes Repo so two repos sharing a (key, version) do not evict each other.
func (h *Handler) evictSuperseded(db *bolthold.Store) {
results, err := db.FindAggregate(&Cache{}, bolthold.Where("Complete").Eq(true).Index("Complete"), "Repo", "Key", "Version")
if err != nil {
h.logger.Warnf("find aggregate caches: %v", err)
} else {
for _, result := range results {
if result.Count() <= 1 {
return
}
var caches []*Cache
for _, result := range results {
if result.Count() <= 1 {
continue
}
result.Sort("CreatedAt")
caches = caches[:0]
result.Reduction(&caches)
for _, cache := range caches[:len(caches)-1] {
if inUse(cache) {
continue
}
result.Sort("CreatedAt")
caches = caches[:0]
result.Reduction(&caches)
for _, cache := range caches[:len(caches)-1] {
if time.Since(time.Unix(cache.UsedAt, 0)) < keepOld {
// Keep it since it has been used recently, even if it's old.
// Or it could break downloading in process.
continue
}
h.storage.Remove(cache.ID)
if err := db.Delete(cache.ID, cache); err != nil {
h.logger.Warnf("delete cache: %v", err)
continue
}
h.logger.Infof("deleted cache: %+v", cache)
}
h.deleteCache(db, cache)
}
}
}
// evictOversized applies the per-repository limit, then the whole-store one. Only completed
// entries count, since only those carry a size measured at commit rather than claimed.
func (h *Handler) evictOversized(db *bolthold.Store) {
if h.policy.RepoSizeLimit > 0 {
byRepo := make(map[string][]*Cache)
for _, cache := range h.completedByUse(db) {
byRepo[cache.Repo] = append(byRepo[cache.Repo], cache)
}
for repo, caches := range byRepo {
h.evictTo(db, caches, h.policy.RepoSizeLimit, "repository "+repo)
}
}
h.evictTotal(db)
}
// evictTotal caps the store as a whole. It re-queries because the per-repo pass may have
// deleted rows an earlier result still holds.
func (h *Handler) evictTotal(db *bolthold.Store) {
if h.policy.SizeLimit <= 0 {
return
}
h.evictTo(db, h.completedByUse(db), h.policy.SizeLimit, "the cache")
}
// evictRepo reclaims space when a commit pushes a repo over, rather than at the next sweep.
func (h *Handler) evictRepo(db *bolthold.Store, repo string) {
if h.policy.RepoSizeLimit <= 0 {
return
}
h.evictTo(db, h.cachesByUse(db, bolthold.Where("Repo").Eq(repo).And("Complete").Eq(true).Index("Repo")), h.policy.RepoSizeLimit, "repository "+repo)
}
// evictTo deletes until caches fit limit. caches must be ordered by UsedAt ascending.
func (h *Handler) evictTo(db *bolthold.Store, caches []*Cache, limit int64, scope string) {
// An entry bigger than the limit never fits, so it goes on its own account instead of
// dragging every neighbour out first and then following them next sweep.
fits := caches[:0]
for _, cache := range caches {
if cache.Size <= limit {
fits = append(fits, cache)
continue
}
if !inUse(cache) {
h.logger.Warnf("cache %q is %d MiB on its own, over the limit for %s; dropping it", cache.Key, cache.Size/miB, scope)
h.deleteCache(db, cache)
}
}
caches = fits
total := totalSize(caches)
var freed int64
for _, cache := range caches {
if total <= limit {
break
}
if inUse(cache) || !h.deleteCache(db, cache) {
continue
}
total -= cache.Size
freed += cache.Size
}
if freed > 0 {
h.logger.Warnf("evicted %d MiB from %s, least recently used first", freed/miB, scope)
}
}
// inUse reports whether an entry was read or written recently enough that removing it
// could break a download in progress.
func inUse(cache *Cache) bool {
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
}
// touchNeeded skips a write that would not change the second, and completed entries fresher than touchStale.
func touchNeeded(cache *Cache) bool {
return cache.UsedAt < time.Now().Unix() && (!cache.Complete || time.Since(time.Unix(cache.UsedAt, 0)) >= touchStale)
}
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
// go through touchCache, which opens its own store and would block on the exclusive lock
// for as long as the caller holds one.
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
if !touchNeeded(cache) {
return
}
cache.UsedAt = time.Now().Unix()
if err := db.Update(cache.ID, cache); err != nil {
h.logger.Warnf("touch cache: %v", err)
}
}
func (h *Handler) sweep(db *bolthold.Store, query *bolthold.Query) {
for _, cache := range h.caches(db, query) {
h.deleteCache(db, cache)
}
}
func (h *Handler) caches(db *bolthold.Store, query *bolthold.Query) []*Cache {
var caches []*Cache
if err := db.Find(&caches, query); err != nil {
h.logger.Warnf("find caches: %v", err)
}
return caches
}
// cachesByUse returns matches least recently accessed first, sorting here rather than with
// bolthold's SortBy, which reflects over every field it compares.
func (h *Handler) cachesByUse(db *bolthold.Store, query *bolthold.Query) []*Cache {
caches := h.caches(db, query)
slices.SortFunc(caches, func(a, b *Cache) int { return cmp.Compare(a.UsedAt, b.UsedAt) })
return caches
}
// completedByUse returns every entry the size limits count, least recently accessed first.
func (h *Handler) completedByUse(db *bolthold.Store) []*Cache {
return h.cachesByUse(db, bolthold.Where("Complete").Eq(true).Index("Complete"))
}
func totalSize(caches []*Cache) int64 {
var total int64
for _, cache := range caches {
total += cache.Size
}
return total
}
// 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 {
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
}
if err := db.Delete(cache.ID, cache); err != nil {
h.logger.Warnf("delete cache: %v", err)
return false
}
h.logger.Infof("deleted cache: %+v", cache)
return true
}
func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var data []byte

View File

@@ -7,10 +7,12 @@ package artifactcache
import (
"bytes"
"crypto/rand"
"encoding/json"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -41,18 +43,74 @@ func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
var testClient = &http.Client{Transport: &bearerTransport{token: testToken}}
// testRetention mirrors config.DefaultCacheRetention; Policy has no defaults of its own.
const testRetention = 7 * 24 * time.Hour
// signArtifactURL builds a signed download URL the same way the server does;
// tests use it to reach the get handler directly without going through a
// find/cache-hit round trip.
func signArtifactURL(h *Handler, id int64) string {
return h.signedArtifactURL(uint64(id), time.Now().Add(artifactURLTTL))
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
}
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(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
handler.RegisterJob(testToken, testRepo)
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
base := fmt.Sprintf("%s%s", handler.ExternalURL(), apiPath)
@@ -132,7 +190,7 @@ func TestHandler(t *testing.T) {
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&first))
require.NoError(t, json.UnmarshalRead(resp.Body, &first))
assert.NotZero(t, first.CacheID)
}
{
@@ -147,7 +205,7 @@ func TestHandler(t *testing.T) {
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&second))
require.NoError(t, json.UnmarshalRead(resp.Body, &second))
assert.NotZero(t, second.CacheID)
}
@@ -200,7 +258,7 @@ func TestHandler(t *testing.T) {
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
id = got.CacheID
}
{
@@ -255,7 +313,7 @@ func TestHandler(t *testing.T) {
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
id = got.CacheID
}
{
@@ -311,7 +369,7 @@ func TestHandler(t *testing.T) {
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
id = got.CacheID
}
{
@@ -358,7 +416,7 @@ func TestHandler(t *testing.T) {
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
id = got.CacheID
}
@@ -409,7 +467,7 @@ func TestHandler(t *testing.T) {
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
id = got.CacheID
}
{
@@ -489,7 +547,7 @@ func TestHandler(t *testing.T) {
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, keys[except], got.CacheKey)
@@ -524,7 +582,7 @@ func TestHandler(t *testing.T) {
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, key, got.CacheKey)
assert.NotEqual(t, strings.ToLower(key), got.CacheKey)
@@ -573,7 +631,7 @@ func TestHandler(t *testing.T) {
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := testClient.Get(got.ArchiveLocation)
@@ -629,7 +687,7 @@ func TestHandler(t *testing.T) {
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := testClient.Get(got.ArchiveLocation)
@@ -656,7 +714,7 @@ func backdateCache(t *testing.T, handler *Handler, key string, age time.Duration
require.NoError(t, db.Update(caches[0].ID, caches[0]))
}
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { //nolint:unparam // pre-existing issue from nektos/act
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) {
var id uint64
{
body, err := json.Marshal(&Request{
@@ -673,7 +731,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
id = got.CacheID
}
{
@@ -704,7 +762,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, key, got.CacheKey)
archiveLocation = got.ArchiveLocation
@@ -722,7 +780,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
func TestHandler_gcCache(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir, Policy: Policy{Retention: testRetention}})
require.NoError(t, err)
defer func() {
@@ -752,8 +810,8 @@ func TestHandler_gcCache(t *testing.T) {
Key: "test_key_2",
Version: "test_version",
Complete: false,
UsedAt: now.Add(-(keepTemp + time.Second)).Unix(),
CreatedAt: now.Add(-(keepTemp + time.Hour)).Unix(),
UsedAt: now.Add(-(inUseGrace + time.Second)).Unix(),
CreatedAt: now.Add(-(inUseGrace + time.Hour)).Unix(),
},
Kept: false,
},
@@ -763,21 +821,21 @@ func TestHandler_gcCache(t *testing.T) {
Key: "test_key_3",
Version: "test_version",
Complete: true,
UsedAt: now.Add(-(keepUnused + time.Second)).Unix(),
CreatedAt: now.Add(-(keepUnused + time.Hour)).Unix(),
UsedAt: now.Add(-(testRetention + touchStale + time.Second)).Unix(),
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
},
Kept: false,
},
{
// should be removed, since it's used but too old.
// should be kept, since age alone does not retire an entry that is still used.
Cache: &Cache{
Key: "test_key_3",
Version: "test_version",
Complete: true,
UsedAt: now.Unix(),
CreatedAt: now.Add(-(keepUsed + time.Second)).Unix(),
CreatedAt: now.Add(-365 * 24 * time.Hour).Unix(),
},
Kept: false,
Kept: true,
},
{
// should be kept, since it has a newer edition but be used recently.
@@ -785,7 +843,7 @@ func TestHandler_gcCache(t *testing.T) {
Key: "test_key_1",
Version: "test_version",
Complete: true,
UsedAt: now.Add(-(keepOld - time.Minute)).Unix(),
UsedAt: now.Add(-(inUseGrace - time.Minute)).Unix(),
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
},
Kept: true,
@@ -796,7 +854,7 @@ func TestHandler_gcCache(t *testing.T) {
Key: "test_key_1",
Version: "test_version",
Complete: true,
UsedAt: now.Add(-(keepOld + time.Second)).Unix(),
UsedAt: now.Add(-(inUseGrace + time.Second)).Unix(),
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
},
Kept: false,
@@ -806,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())
@@ -829,11 +887,289 @@ func TestHandler_gcCache(t *testing.T) {
require.NoError(t, db.Close())
}
// TestHandler_evictPolicy covers the non-default policies; TestHandler_gcCache covers the
// defaults across every pass.
func TestHandler_evictPolicy(t *testing.T) {
now := time.Now()
stale := func(d time.Duration) int64 { return now.Add(-d).Unix() }
mib := func(n int64) int64 { return n * miB }
for _, tc := range []struct {
name string
policy Policy
entries []*Cache
kept []string
}{
{
name: "a zero retention keeps an entry nothing has touched",
policy: Policy{Retention: 0},
entries: []*Cache{
{Key: "idle", UsedAt: stale(testRetention + time.Hour)},
},
kept: []string{"idle"},
},
{
name: "evicts least recently accessed until the repository fits",
policy: Policy{RepoSizeLimit: mib(10)},
entries: []*Cache{
{Repo: "o/a", Key: "oldest", Size: mib(4), UsedAt: stale(3 * time.Hour)},
{Repo: "o/a", Key: "middle", Size: mib(4), UsedAt: stale(2 * time.Hour)},
{Repo: "o/a", Key: "newest", Size: mib(4), UsedAt: stale(time.Hour)},
},
kept: []string{"middle", "newest"},
},
{
name: "spares entries that may still be downloading",
policy: Policy{RepoSizeLimit: mib(10)},
entries: []*Cache{
{Repo: "o/a", Key: "fresh_1", Size: mib(6), UsedAt: stale(time.Minute)},
{Repo: "o/a", Key: "fresh_2", Size: mib(6), UsedAt: stale(time.Minute)},
},
kept: []string{"fresh_1", "fresh_2"},
},
{
name: "one repository over its limit leaves another alone",
policy: Policy{RepoSizeLimit: mib(10)},
entries: []*Cache{
{Repo: "o/a", Key: "a_old", Size: mib(6), UsedAt: stale(3 * time.Hour)},
{Repo: "o/a", Key: "a_new", Size: mib(6), UsedAt: stale(time.Hour)},
{Repo: "o/b", Key: "b_old", Size: mib(6), UsedAt: stale(4 * time.Hour)},
},
kept: []string{"a_new", "b_old"},
},
{
name: "the total limit evicts across repositories once each fits its own",
policy: Policy{RepoSizeLimit: mib(10), SizeLimit: mib(12)},
entries: []*Cache{
{Repo: "o/a", Key: "a_old", Size: mib(8), UsedAt: stale(3 * time.Hour)},
{Repo: "o/b", Key: "b_new", Size: mib(8), UsedAt: stale(time.Hour)},
},
kept: []string{"b_new"},
},
{
// Retention below inUseGrace would otherwise drop an entry whose signed URL a job
// is still holding.
name: "a retention shorter than the grace still spares a just-served entry",
policy: Policy{Retention: time.Minute},
entries: []*Cache{
{Key: "just_served", UsedAt: stale(2 * time.Minute)},
{Key: "idle", UsedAt: stale(time.Hour)},
},
kept: []string{"just_served"},
},
{
name: "an entry over the limit goes without emptying the repository",
policy: Policy{RepoSizeLimit: mib(10)},
entries: []*Cache{
{Repo: "o/a", Key: "keeps", Size: mib(4), UsedAt: stale(3 * time.Hour)},
{Repo: "o/a", Key: "huge", Size: mib(20), UsedAt: stale(2 * time.Hour)},
},
kept: []string{"keeps"},
},
{
name: "a zero limit keeps everything",
policy: Policy{RepoSizeLimit: 0},
entries: []*Cache{
{Repo: "o/a", Key: "huge_1", Size: mib(100), UsedAt: stale(3 * time.Hour)},
{Repo: "o/a", Key: "huge_2", Size: mib(100), UsedAt: stale(2 * time.Hour)},
},
kept: []string{"huge_1", "huge_2"},
},
} {
t.Run(tc.name, func(t *testing.T) {
for _, e := range tc.entries {
e.Complete = true // only completed entries carry a measured size, so only they count
}
handler := newTestHandler(t, tc.policy, tc.entries...)
handler.gcAt = time.Time{} // ensure gcCache will not skip
handler.gcCache()
assert.ElementsMatch(t, tc.kept, keptKeys(t, handler, tc.entries))
})
}
}
// TestHandler_evictForFreeSpace proves the volume backstop sheds only what it must, and only
// when the disk is actually short.
func TestHandler_evictForFreeSpace(t *testing.T) {
free := func(n int64) func(string) (uint64, error) {
return func(string) (uint64, error) { return uint64(n), nil }
}
for _, tc := range []struct {
name string
freeDisk func(string) (uint64, error)
kept []string
}{
{"ample free space evicts nothing", free(defaultMinFreeDisk), []string{"oldest", "middle", "newest"}},
{"a small shortfall sheds one entry", free(defaultMinFreeDisk - 4*miB), []string{"middle", "newest"}},
{"a shortfall the cache cannot cover sheds all of it", free(0), nil},
{
"an unreadable volume is treated as unavailable, not as full",
func(string) (uint64, error) { return 0, errors.New("unsupported") },
[]string{"oldest", "middle", "newest"},
},
} {
t.Run(tc.name, func(t *testing.T) {
now := time.Now()
entries := []*Cache{
{Key: "oldest", Complete: true, Size: 4 * miB, UsedAt: now.Add(-3 * time.Hour).Unix()},
{Key: "middle", Complete: true, Size: 4 * miB, UsedAt: now.Add(-2 * time.Hour).Unix()},
{Key: "newest", Complete: true, Size: 4 * miB, UsedAt: now.Add(-time.Hour).Unix()},
}
handler := newTestHandler(t, Policy{}, entries...)
handler.freeDisk = tc.freeDisk
db, err := handler.openDB()
require.NoError(t, err)
handler.evictForFreeSpace(db)
require.NoError(t, db.Close())
assert.ElementsMatch(t, tc.kept, keptKeys(t, handler, entries))
})
}
}
// TestHandler_SweepKeepsEntryWhenBlobSurvives proves a failed unlink leaves the row in place,
// so the next sweep retries rather than orphaning bytes no row points at and no limit counts.
func TestHandler_SweepKeepsEntryWhenBlobSurvives(t *testing.T) {
cache := &Cache{Key: "stuck", Complete: true, UsedAt: time.Now().Add(-(testRetention + time.Hour)).Unix()}
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.(*Storage).filename(cache.ID)
require.NoError(t, os.MkdirAll(blob, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600))
handler.gcAt = time.Time{}
handler.gcCache()
assert.Equal(t, []string{"stuck"}, keptKeys(t, handler, []*Cache{cache}), "the entry must outlive a blob that could not be removed")
}
// TestHandler_FindProtectsFromEviction covers the window between a find handing out a signed
// download URL and the GET that redeems it: the entry promised to a job must not be the next
// eviction victim just because its last access predates the find.
func TestHandler_FindProtectsFromEviction(t *testing.T) {
// 12 MiB against a 10 MiB limit, so exactly one entry has to go.
wanted := &Cache{Repo: testRepo, Key: "wanted", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-3 * time.Hour).Unix()}
other := &Cache{Repo: testRepo, Key: "other", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-2 * time.Hour).Unix()}
newest := &Cache{Repo: testRepo, Key: "newest", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
handler := newTestHandler(t, Policy{RepoSizeLimit: 10 * miB}, wanted, other, newest)
writeBlob(t, handler, wanted.ID) // find only reports a hit when the blob is on disk
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=wanted&version=v", handler.ExternalURL(), apiPath))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
// Evict directly: the request above kicked off an async gcCache, and writing gcAt here
// to drive gcCache would race its read.
db, err := handler.openDB()
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
handler.evictOversized(db)
require.NoError(t, db.Get(wanted.ID, &Cache{}), "the entry just promised to a job must survive")
assert.ErrorIs(t, db.Get(other.ID, &Cache{}), bolthold.ErrNotFound, "the next least recently used goes instead")
}
// TestHandler_evictOnCommit proves a repository that goes over its limit gets space back at
// once, rather than waiting out the collection interval.
func TestHandler_evictOnCommit(t *testing.T) {
full := &Cache{Repo: testRepo, Key: "full", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
handler := newTestHandler(t, Policy{RepoSizeLimit: 4 * miB}, full)
// StartHandler already stamped gcAt, so the periodic sweep stays rate-limited out and
// only the commit path can evict.
uploadCacheNormally(t, handler.ExternalURL()+apiPath, "new", "v", []byte("some content"))
assert.Empty(t, keptKeys(t, handler, []*Cache{full}))
}
func TestHandler_gcCacheInterval(t *testing.T) {
cache := &Cache{Key: "temp", UsedAt: time.Now().Add(-time.Hour).Unix()}
// Half the default, so a sweep 45m ago is still inside the default but past this one.
handler := newTestHandler(t, Policy{SweepInterval: 30 * time.Minute}, cache)
handler.gcAt = time.Now().Add(-45 * time.Minute) // past the configured interval, still inside the default
handler.gcCache()
assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
}
func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
fresh := &Cache{Repo: testRepo, Key: "fresh", Version: "v", Complete: true, UsedAt: time.Now().Unix()}
pending := &Cache{Repo: testRepo, Key: "pending", Version: "v", UsedAt: time.Now().Add(-time.Second).Unix()}
handler := newTestHandler(t, Policy{}, fresh, pending)
txID := func() int {
db, err := handler.openDB()
require.NoError(t, err)
defer db.Close()
var id int
require.NoError(t, db.Bolt().View(func(tx *bbolt.Tx) error { id = tx.ID(); return nil }))
return id
}
before := txID()
require.NoError(t, handler.touchCache(fresh.ID, false))
assert.Equal(t, before, txID())
require.NoError(t, handler.touchCache(pending.ID, false))
assert.Greater(t, txID(), before)
}
// newTestHandler starts a handler with testToken registered, seeded with entries.
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
t.Helper()
handler := startTestHandler(t, Options{Policy: policy})
db, err := handler.openDB()
require.NoError(t, err)
for _, e := range entries {
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()
db, err := handler.openDB()
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
var kept []string
for _, e := range entries {
if err := db.Get(e.ID, &Cache{}); err == nil {
kept = append(kept, e.Key)
}
}
return kept
}
// writeBlob gives an entry the on-disk bytes that find and get require.
func writeBlob(t *testing.T, handler *Handler, id uint64) {
t.Helper()
require.NoError(t, handler.storage.Write(id, 0, strings.NewReader("a")))
_, err := handler.storage.Commit(id, 1)
require.NoError(t, err)
}
// TestHandler_RejectsMissingBearer covers the advisory's root cause:
// unauthenticated access to management endpoints is now refused with 401.
func TestHandler_RejectsMissingBearer(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
defer handler.Close()
@@ -866,7 +1202,7 @@ func TestHandler_RejectsMissingBearer(t *testing.T) {
// accepted after RegisterJob; stale/forged tokens cannot be replayed.
func TestHandler_RejectsUnknownBearer(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
defer handler.Close()
@@ -886,11 +1222,11 @@ func TestHandler_RejectsUnknownBearer(t *testing.T) {
// working the moment the job ends instead of living for the runner's lifetime.
func TestHandler_UnregisterRevokes(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
defer handler.Close()
unregister := handler.RegisterJob("tmp-token", testRepo)
unregister := handler.RegisterJob("tmp-token", JobCredential{Repo: testRepo})
base := handler.ExternalURL() + apiPath
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
@@ -917,11 +1253,11 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
// invisible to queries scoped to repoB.
func TestHandler_CrossRepoIsolation(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob("token-a", "owner/repoA")
handler.RegisterJob("token-b", "owner/repoB")
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
handler.RegisterJob("token-b", JobCredential{Repo: "owner/repoB"})
base := handler.ExternalURL() + apiPath
key := "shared-key"
@@ -939,7 +1275,7 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
var reserved struct {
CacheID uint64 `json:"cacheId"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&reserved))
require.NoError(t, json.UnmarshalRead(resp.Body, &reserved))
resp.Body.Close()
require.NotZero(t, reserved.CacheID)
@@ -983,10 +1319,10 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
// working after artifactURLTTL even if the bearer token is still registered.
func TestHandler_ArtifactSignature(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob(testToken, testRepo)
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
base := handler.ExternalURL() + apiPath
@@ -998,7 +1334,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
})
t.Run("tampered signature", func(t *testing.T) {
good := handler.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
good := signArtifactURL(handler, 1)
bad := good[:len(good)-4] + "dead"
resp, err := testClient.Get(bad)
require.NoError(t, err)
@@ -1007,7 +1343,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
})
t.Run("expired signature", func(t *testing.T) {
expired := handler.signedArtifactURL(1, time.Now().Add(-time.Second))
expired := handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))
resp, err := testClient.Get(expired)
require.NoError(t, err)
resp.Body.Close()
@@ -1016,10 +1352,10 @@ func TestHandler_ArtifactSignature(t *testing.T) {
t.Run("signature from a different server", func(t *testing.T) {
dir2 := filepath.Join(t.TempDir(), "artifactcache2")
other, err := StartHandler(dir2, "", 0, "", nil)
other, err := StartHandler(Options{Dir: dir2})
require.NoError(t, err)
defer other.Close()
otherURL := other.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
otherURL := signArtifactURL(other, 1)
// Rewrite the host so the request still lands on our handler, but
// the signature was computed with a different secret.
parts := strings.SplitN(otherURL, apiPath, 2)
@@ -1038,13 +1374,13 @@ func TestHandler_ArtifactSignature(t *testing.T) {
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
first, err := StartHandler(Options{Dir: dir, OutboundIP: "127.0.0.1"})
require.NoError(t, err)
exp := time.Now().Add(artifactURLTTL).Unix()
sig := first.computeSignature("", 42, exp)
require.NoError(t, first.Close())
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
second, err := StartHandler(Options{Dir: dir, OutboundIP: "127.0.0.1"})
require.NoError(t, err)
defer second.Close()
@@ -1056,10 +1392,10 @@ func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
// the auth refactor.
func TestHandler_ArtifactSignatureDownload(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob(testToken, testRepo)
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
base := handler.ExternalURL() + apiPath
key := "download-key"
@@ -1073,7 +1409,7 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) {
var hit struct {
ArchiveLocation string `json:"archiveLocation"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&hit))
require.NoError(t, json.UnmarshalRead(resp.Body, &hit))
resp.Body.Close()
require.Contains(t, hit.ArchiveLocation, "sig=")
@@ -1096,12 +1432,12 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) {
// (restart mid-task, retry), which must not kill the live job's auth.
func TestHandler_RegisterJob_RefCounted(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
defer handler.Close()
first := handler.RegisterJob("shared", testRepo)
second := handler.RegisterJob("shared", testRepo)
first := handler.RegisterJob("shared", JobCredential{Repo: testRepo})
second := handler.RegisterJob("shared", JobCredential{Repo: testRepo})
base := handler.ExternalURL() + apiPath
probe := func() int {
@@ -1125,14 +1461,14 @@ func TestHandler_RegisterJob_RefCounted(t *testing.T) {
// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict
// another repo's entry. Two repos reserve the same (key, version); after the
// keepOld window, GC must keep the one from each repo.
// inUseGrace window, GC must keep the one from each repo.
func TestHandler_GC_PerRepoDedup(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob("tok-a", "owner/repoA")
handler.RegisterJob("tok-b", "owner/repoB")
handler.RegisterJob("tok-a", JobCredential{Repo: "owner/repoA"})
handler.RegisterJob("tok-b", JobCredential{Repo: "owner/repoB"})
key := "shared-dedup-key"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
@@ -1142,11 +1478,11 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
db, err := handler.openDB()
require.NoError(t, err)
now := time.Now().Unix()
stale := time.Now().Add(-keepOld - time.Minute).Unix()
stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
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)
@@ -1179,7 +1515,7 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
// register/revoke when the feature is off.
func TestHandler_InternalAPI_Disabled(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(Options{Dir: dir})
require.NoError(t, err)
defer handler.Close()
@@ -1197,7 +1533,7 @@ func TestHandler_InternalAPI_Disabled(t *testing.T) {
func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
const secret = "internal-secret"
handler, err := StartHandler(dir, "", 0, secret, nil)
handler, err := StartHandler(Options{Dir: dir, InternalSecret: secret})
require.NoError(t, err)
defer handler.Close()
@@ -1240,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"))

View File

@@ -5,7 +5,8 @@ package artifactcache
import (
"cmp"
"encoding/json"
"encoding/json/jsontext"
"encoding/json/v2"
"encoding/xml"
"errors"
"fmt"
@@ -23,6 +24,9 @@ import (
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
// Both API versions are served from the same store, so a repository keeps its cache
// when a workflow moves between action versions.
//
// Responses carry the proto field names, which is what Gitea's own results API emits and the only
// spelling the Go clients parse. The JavaScript toolkit accepts either.
const (
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
@@ -35,8 +39,9 @@ const (
blobUploadURLTTL = time.Hour
// twirpInternal is the only error code that is not the client's fault.
twirpInternal = "internal"
twirpInternal = "internal"
twirpUnavailable = "unavailable"
twirpUnauthenticated = "unauthenticated"
)
func (h *Handler) registerV2Routes(router *httprouter.Router) {
@@ -52,7 +57,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2CreateRequest](r)
if err != nil {
h.twirpError(w, r, "malformed_request", err)
h.twirpError(w, r, "malformed", err)
return
}
if req.Key == "" || req.Version == "" {
@@ -74,27 +79,47 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
h.twirpError(w, r, twirpInternal, err)
return
} else if existing != nil {
h.touch(db, existing) // the client skips the upload, so this is the only sign the entry is still in use
h.twirpNotOK(w, r)
return
}
// A second live reservation is finalized by whichever job calls last, against the other's upload.
owner := hashedToken(bearerToken(r))
if pending, err := findExactCache(db, cred.Repo, req.Key, req.Version, false); err != nil {
h.twirpError(w, r, twirpInternal, err)
return
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
if pending.Owner != owner {
h.twirpNotOK(w, r)
return
}
h.touch(db, pending) // still uploading, so it must not go stale under the sweep
h.responseJSON(w, r, http.StatusOK, map[string]any{ // this job retrying its own reservation
"ok": true,
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, pending.ID, time.Now().Add(blobUploadURLTTL)),
})
return
}
now := time.Now().Unix()
cache := &Cache{
Repo: cred.Repo,
Key: req.Key,
Version: req.Version,
Owner: owner,
Size: -1, // the size is only known at finalize time
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
}
h.responseJSON(w, r, http.StatusOK, map[string]any{
"ok": true,
"signedUploadUrl": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
"ok": true,
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
})
}
@@ -102,7 +127,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
if err != nil {
h.twirpError(w, r, "malformed_request", err)
h.twirpError(w, r, "malformed", err)
return
}
@@ -114,6 +139,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
defer db.Close()
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
cache = nil // not the reservation this job made, so not this job's to commit
}
if err == nil && cache == nil {
// A retry whose first response was lost finds the entry already committed.
cache, err = findExactCache(db, cred.Repo, req.Key, req.Version, true)
}
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
@@ -122,19 +154,21 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
h.twirpNotOK(w, r)
return
}
db.Close() // commitCache needs the store closed
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
if err := h.commitCache(cache); err != nil {
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
h.twirpNotOK(w, r)
return
if !cache.Complete {
db.Close() // commitCache needs the store closed
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
if err := h.commitCache(cache); err != nil {
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
h.twirpNotOK(w, r)
return
}
}
h.responseJSON(w, r, http.StatusOK, map[string]any{
"ok": true,
// int64 fields travel as strings in the proto JSON mapping.
"entryId": strconv.FormatUint(cache.ID, 10),
"entry_id": strconv.FormatUint(cache.ID, 10),
})
}
@@ -142,7 +176,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2DownloadRequest](r)
if err != nil {
h.twirpError(w, r, "malformed_request", err)
h.twirpError(w, r, "malformed", err)
return
}
@@ -164,9 +198,9 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
}
h.responseJSON(w, r, http.StatusOK, map[string]any{
"ok": true,
"signedDownloadUrl": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
"matchedKey": cache.Key,
"ok": true,
"signed_download_url": h.signedArtifactURL(cred, cache.ID, time.Now().Add(artifactURLTTL)),
"matched_key": cache.Key,
})
}
@@ -209,6 +243,8 @@ func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params ht
return
}
// The Azure SDK client dereferences this without checking, so its absence panics the caller.
w.Header().Set("x-ms-request-id", strconv.FormatInt(time.Now().UnixNano(), 10))
w.WriteHeader(http.StatusCreated)
}
@@ -223,8 +259,13 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
status := http.StatusBadRequest
if code == twirpInternal {
switch code {
case twirpInternal:
status = http.StatusInternalServerError
case twirpUnavailable:
status = http.StatusServiceUnavailable
case twirpUnauthenticated:
status = http.StatusUnauthorized
}
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
}
@@ -239,10 +280,10 @@ type (
}
v2FinalizeRequest struct {
Key string `json:"key"`
Version string `json:"version"`
SizeBytes json.Number `json:"size_bytes"`
SizeBytesCamel json.Number `json:"sizeBytes"`
Key string `json:"key"`
Version string `json:"version"`
SizeBytes twirpInt64 `json:"size_bytes"`
SizeBytesCamel twirpInt64 `json:"sizeBytes"`
}
v2DownloadRequest struct {
@@ -253,6 +294,31 @@ type (
}
)
// twirpInt64 accepts its value as the JSON string the mapping prescribes or as a bare number.
type twirpInt64 int64
func (n *twirpInt64) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
val, err := dec.ReadValue()
if err != nil {
return err
}
digits := []byte(val)
switch val.Kind() {
case 'n': // absent, keep the zero value
return nil
case '"':
if digits, err = jsontext.AppendUnquote(nil, val); err != nil {
return err
}
}
parsed, err := strconv.ParseInt(string(digits), 10, 64)
if err != nil {
return err
}
*n = twirpInt64(parsed)
return nil
}
func (d v2DownloadRequest) keys() []string {
restoreKeys := d.RestoreKeys
if len(restoreKeys) == 0 {
@@ -263,6 +329,6 @@ func (d v2DownloadRequest) keys() []string {
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
var req T
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
err := json.UnmarshalRead(io.LimitReader(r.Body, 1<<20), &req)
return req, err
}

View File

@@ -6,12 +6,13 @@ package artifactcache
import (
"bytes"
"encoding/base64"
"encoding/json"
"encoding/json/v2"
"fmt"
"io"
"net/http"
"path/filepath"
"net/url"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -32,7 +33,7 @@ func v2Call(t *testing.T, handler *Handler, client *http.Client, method string,
require.Equal(t, http.StatusOK, resp.StatusCode)
got := map[string]any{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
return got
}
@@ -45,6 +46,11 @@ func putBlob(t *testing.T, url string, content []byte) int {
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated {
// The Azure SDK client dereferences this header without checking, so a blob upload that
// omits it panics the caller rather than failing it.
require.NotEmpty(t, resp.Header.Get("x-ms-request-id"))
}
return resp.StatusCode
}
@@ -61,16 +67,6 @@ func getURL(t *testing.T, url string) []byte {
return body
}
func startTestHandler(t *testing.T) *Handler {
t.Helper()
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
require.NoError(t, err)
t.Cleanup(func() { _ = handler.Close() })
handler.RegisterJob(testToken, testRepo)
return handler
}
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
// with the upload URL it used.
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
@@ -78,7 +74,7 @@ func saveV2(t *testing.T, handler *Handler, key, version string, content []byte)
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
require.Equal(t, true, created["ok"])
uploadURL, _ = created["signedUploadUrl"].(string)
uploadURL, _ = created["signed_upload_url"].(string)
require.NotEmpty(t, uploadURL)
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
@@ -92,7 +88,7 @@ func saveV2(t *testing.T, handler *Handler, key, version string, content []byte)
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
// or to replace a finalized entry.
func TestCacheServiceV2RoundTrip(t *testing.T) {
handler := startTestHandler(t)
handler := newTestHandler(t, Policy{})
content := []byte("the cached archive")
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
@@ -100,7 +96,7 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
require.Equal(t, true, finalized["ok"])
assert.NotEmpty(t, finalized["entryId"])
assert.NotEmpty(t, finalized["entry_id"])
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
// and it is an upload URL only: nothing reads a blob back through it.
@@ -112,8 +108,8 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
require.Equal(t, true, got["ok"])
assert.Equal(t, "deps-v1", got["matchedKey"])
downloadURL, _ := got["signedDownloadUrl"].(string)
assert.Equal(t, "deps-v1", got["matched_key"])
downloadURL, _ := got["signed_download_url"].(string)
require.NotEmpty(t, downloadURL)
assert.Equal(t, content, getURL(t, downloadURL))
}
@@ -121,10 +117,10 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
// A large archive is staged as blocks and only put in order by the final block list, so
// blocks that arrive out of order must still be assembled the way the client asked.
func TestCacheServiceV2BlockUpload(t *testing.T) {
handler := startTestHandler(t)
handler := newTestHandler(t, Policy{})
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
uploadURL, _ := created["signedUploadUrl"].(string)
uploadURL, _ := created["signed_upload_url"].(string)
require.NotEmpty(t, uploadURL)
blocks := map[string][]byte{}
@@ -146,6 +142,17 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
}
list.WriteString(`</BlockList>`)
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()),
"a client that lost the first answer retries the list it already sent")
var reordered bytes.Buffer
reordered.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
for _, blockID := range []string{order[1], order[0], order[2]} {
fmt.Fprintf(&reordered, "<Latest>%s</Latest>", blockID)
}
reordered.WriteString(`</BlockList>`)
assert.Equal(t, http.StatusInternalServerError, putBlob(t, uploadURL+"&comp=blocklist", reordered.Bytes()),
"the blocks are already assembled, so a different order would silently disagree with them")
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
@@ -154,11 +161,11 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
require.Equal(t, true, got["ok"])
assert.Equal(t, "hello world!", string(getURL(t, got["signedDownloadUrl"].(string))))
assert.Equal(t, "hello world!", string(getURL(t, got["signed_download_url"].(string))))
}
func TestCacheServiceV2Lookups(t *testing.T) {
handler := startTestHandler(t)
handler := newTestHandler(t, Policy{})
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
require.Equal(t, true, saved["ok"])
@@ -175,7 +182,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
})
require.Equal(t, true, got["ok"])
assert.Equal(t, "deps-abc", got["matchedKey"])
assert.Equal(t, "deps-abc", got["matched_key"])
})
}
@@ -190,7 +197,19 @@ func TestCacheServiceV2Lookups(t *testing.T) {
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
require.Equal(t, true, reserved["ok"])
assert.NotEmpty(t, reserved["signedUploadUrl"])
assert.NotEmpty(t, reserved["signed_upload_url"])
})
t.Run("a proxied job is handed the address its runner registered", func(t *testing.T) {
const proxy = "https://cache.example.invalid"
handler.RegisterJob("proxied", JobCredential{Repo: testRepo, PublicURL: proxy + "/"})
client := &http.Client{Transport: &bearerTransport{token: "proxied"}}
created := v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "proxied-key", "version": "v1"})
assert.True(t, strings.HasPrefix(created["signed_upload_url"].(string), proxy+blobPath+"/"))
got := v2Call(t, handler, client, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
assert.True(t, strings.HasPrefix(got["signed_download_url"].(string), proxy+apiPath+"/artifacts/"))
})
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
@@ -203,7 +222,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
// The size the client declares is what Commit validates the assembled archive against.
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
require.Equal(t, http.StatusCreated, putBlob(t, created["signedUploadUrl"].(string), []byte("four")))
require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte("four")))
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "wrong-size", "version": "v1", "size_bytes": 99,
@@ -220,17 +239,115 @@ func TestCacheServiceV2Lookups(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
got := map[string]any{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, "deps-abc", got["cacheKey"])
assert.NotEmpty(t, got["archiveLocation"])
})
// The cache of one repository must stay invisible to another, as it does for the v1 API.
t.Run("another repository sees nothing", func(t *testing.T) {
handler.RegisterJob("other-runtime-token", "other/repo")
handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"})
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
assert.Equal(t, false, got["ok"])
})
}
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
handler := newTestHandler(t, Policy{})
tests := []struct {
name string
token string
body string
status int
code string
}{
{"no bearer at all", "", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
{"a bearer nobody registered", "not-a-job", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
{"a body that is not the request", testToken, `{`, http.StatusBadRequest, "malformed"},
{"a request missing its key", testToken, `{"version":"v"}`, http.StatusBadRequest, "invalid_argument"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", strings.NewReader(tt.body))
require.NoError(t, err)
if tt.token != "" {
req.Header.Set("Authorization", "Bearer "+tt.token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, tt.status, resp.StatusCode)
got := map[string]string{}
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
assert.Equal(t, tt.code, got["code"])
assert.NotEmpty(t, got["msg"])
})
}
}
func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
handler := newTestHandler(t, Policy{})
content := []byte("the cached archive")
const otherJob = "another-jobs-token"
defer handler.RegisterJob(otherJob, JobCredential{Repo: testRepo})()
reserve := func(client *http.Client) map[string]any {
return v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "shared", "version": "v1"})
}
first := reserve(testClient)
require.Equal(t, true, first["ok"])
retry := reserve(testClient)
assert.Equal(t, true, retry["ok"], "a job retrying its own reservation is not a conflict")
entryOf := func(reserved map[string]any) string {
parsed, err := url.Parse(reserved["signed_upload_url"].(string))
require.NoError(t, err)
return parsed.Path
}
assert.Equal(t, entryOf(first), entryOf(retry), "the same reservation, however freshly its URL is signed")
assert.Equal(t, false, reserve(&http.Client{Transport: &bearerTransport{token: otherJob}})["ok"],
"another job's reservation would be finalized against the first upload")
otherClient := &http.Client{Transport: &bearerTransport{token: otherJob}}
reserved := v2Call(t, handler, otherClient, "CreateCacheEntry", map[string]any{"key": "owned", "version": "v1"})
require.Equal(t, true, reserved["ok"])
require.Equal(t, http.StatusCreated, putBlob(t, reserved["signed_upload_url"].(string), content))
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
map[string]any{"key": "owned", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
"another job's upload is not this job's to finalize")
base := handler.ExternalURL() + apiPath
reserveV1, err := json.Marshal(&Request{Key: "v1-made", Version: "v1", Size: int64(len(content))})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(reserveV1))
require.NoError(t, err)
var madeByV1 struct {
CacheID uint64 `json:"cacheId"`
}
require.NoError(t, json.UnmarshalRead(resp.Body, &madeByV1))
resp.Body.Close()
upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, madeByV1.CacheID), bytes.NewReader(content))
require.NoError(t, err)
upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
resp, err = testClient.Do(upload)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
map[string]any{"key": "v1-made", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
"a reservation this job did not make through v2 is not its to commit")
finalized, _ := saveV2(t, handler, "deps", "v1", content)
require.Equal(t, true, finalized["ok"])
again := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "deps", "version": "v1", "size_bytes": strconv.Itoa(len(content)),
})
assert.Equal(t, true, again["ok"], "a retry whose first answer was lost must not report a failed save")
assert.Equal(t, finalized["entry_id"], again["entry_id"])
}

View File

@@ -10,29 +10,13 @@ type Request struct {
Size int64 `json:"cacheSize"`
}
func (c *Request) ToCache() *Cache {
if c == nil {
return nil
}
ret := &Cache{
Key: c.Key,
Version: c.Version,
Size: c.Size,
}
if c.Size == 0 {
// So the request comes from old versions of actions, like `actions/cache@v2`.
// It doesn't send cache size. Set it to -1 to indicate that.
ret.Size = -1
}
return ret
}
type Cache struct {
ID uint64 `json:"id" boltholdKey:"ID"`
Repo string `json:"repo" boltholdIndex:"Repo"`
Key string `json:"key" boltholdIndex:"Key"`
Version string `json:"version" boltholdIndex:"Version"`
Size int64 `json:"cacheSize"`
Owner string `json:"owner"` // hashed bearer, so a job's own retry is not another job's reservation
Complete bool `json:"complete" boltholdIndex:"Complete"`
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`

View File

@@ -0,0 +1,95 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// The results service is one origin serving every github.actions.results.api.v1 service, and
// 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.
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
// registered with, and the 404 the router would have written otherwise.
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
http.NotFound(w, r)
return
}
cred, ok := h.lookupCredential(bearerToken(r))
if !ok {
h.twirpError(w, r, twirpUnauthenticated, errors.New("unknown bearer token"))
return
}
if cred.Results == "" {
h.twirpError(w, r, twirpInternal, errors.New("no instance is registered for this job"))
return
}
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
if err == nil && (target.Hostname() == "" || (target.Scheme != "http" && target.Scheme != "https")) {
err = errors.New("not an absolute http or https URL the job could use")
}
if err != nil {
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
h.twirpError(w, r, twirpInternal, fmt.Errorf("artifact service address %q is unusable: %w", cred.Results, err))
return
}
if !h.mustProxy(r, cred, target) {
redirect := target.JoinPath(r.URL.EscapedPath())
redirect.RawQuery = mergeQuery(target.RawQuery, r.URL.RawQuery)
h.logger.Debugf("%s %s: redirecting to %s", r.Method, r.URL.Path, target)
http.Redirect(w, r, redirect.String(), http.StatusTemporaryRedirect)
return
}
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target)
// Gitea builds the URLs it hands back from this Host, and their scheme from the
// connection unless a forwarded header overrides it, so artifact bodies go to Gitea
// directly and never through here.
r.Out.Host = target.Host
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
h.logger.Warnf("artifact service forward to %s: %v", target, err)
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
},
}
if cred.InsecureTLS {
proxy.Transport = insecureTransport
}
proxy.ServeHTTP(w, r)
}
// A protocol switch loses the bearer or the agent, and an untrusted instance needs skipped verification.
func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL) bool {
if cred.InsecureTLS && target.Scheme == "https" {
return true
}
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
scheme = "http"
}
return scheme != target.Scheme
}
func mergeQuery(target, request string) string {
if target == "" || request == "" {
return target + request
}
return target + "&" + request
}
// insecureTransport is shared, because a transport per request would pool no connections.
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

View File

@@ -0,0 +1,134 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"cmp"
"encoding/json/v2"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFrontResultsService(t *testing.T) {
var gotHost, gotPath, gotProto string
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHost, gotPath, gotProto = r.Host, r.URL.Path, r.Header.Get("X-Forwarded-Proto")
_, _ = io.WriteString(w, `{"ok":true}`)
}))
defer gitea.Close()
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
defer selfSigned.Close()
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
require.NoError(t, err)
defer handler.Close()
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
noRedirect := func(token string) *http.Client {
return &http.Client{
Transport: &bearerTransport{token: token},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
}
post := func(path string) (int, map[string]string) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
require.NoError(t, err)
resp, err := noRedirect(token).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body := map[string]string{}
_ = json.UnmarshalRead(resp.Body, &body)
return resp.StatusCode, body
}
status, body := post(rpc)
assert.Equal(t, http.StatusUnauthorized, status, "an unregistered token is told so, not left to parse a 404 page")
assert.Equal(t, twirpUnauthenticated, body["code"])
assert.Contains(t, body["msg"], "bearer")
assert.NotEmpty(t, body["msg"])
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: selfSigned.URL, InsecureTLS: true})()
status, _ = post(rpc)
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)
gotPath = ""
status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
assert.Equal(t, http.StatusNotFound, status)
status, _ = post("/api/v1/repos/owner/repo")
assert.Equal(t, http.StatusNotFound, status)
assert.Empty(t, gotPath, "only the artifact service is forwarded")
tests := []struct {
name string
cred JobCredential
forwardedProto string
reqQuery string
want int
location string
wantMsg string
}{
{name: "an instance the job reaches itself", cred: JobCredential{Results: gitea.URL}, want: http.StatusTemporaryRedirect},
{name: "a sub-path instance keeps its prefix", cred: JobCredential{Results: gitea.URL + "/sub"}, want: http.StatusTemporaryRedirect},
{name: "an instance with no certificate to distrust", cred: JobCredential{Results: gitea.URL, InsecureTLS: true}, want: http.StatusTemporaryRedirect},
{
name: "both queries survive", cred: JobCredential{Results: gitea.URL + "?tenant=one"}, reqQuery: "?run=7",
want: http.StatusTemporaryRedirect, location: gitea.URL + rpc + "?tenant=one&run=7",
},
{
name: "a trusted https instance is reached by the job itself",
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, 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 forwarded https scheme onto an http instance is proxied",
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
},
{
name: "a scheme the job could not use is refused before it is sent one",
cred: JobCredential{Results: "ftp://gitea.example"}, want: http.StatusInternalServerError, wantMsg: "unusable",
},
{
name: "an address with no host is refused before it is sent one",
cred: JobCredential{Results: "http://:3000"}, want: http.StatusInternalServerError, wantMsg: "unusable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer handler.RegisterJob(tt.name, tt.cred)()
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+rpc+tt.reqQuery, nil)
require.NoError(t, err)
if tt.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
resp, err := noRedirect(tt.name).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, tt.want, resp.StatusCode)
switch tt.want {
case http.StatusTemporaryRedirect:
assert.Equal(t, cmp.Or(tt.location, tt.cred.Results+rpc), resp.Header.Get("Location"))
case http.StatusInternalServerError:
body := map[string]string{}
require.NoError(t, json.UnmarshalRead(resp.Body, &body))
assert.Equal(t, twirpInternal, body["code"])
assert.Contains(t, body["msg"], cmp.Or(tt.wantMsg, tt.cred.Results))
}
})
}
}

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
}

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"))
}

View File

@@ -5,8 +5,10 @@
package artifactcache
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"fmt"
"io"
"net/http"
@@ -66,6 +68,18 @@ func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
// never does, so no rename can collide with a block not yet moved.
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
wanted, err := json.Marshal(blockIDs) // a block id is client-chosen, so it cannot be a separator
if err != nil {
return err
}
list := filepath.Join(s.tempDir(id), blockFilePrefix+"list")
if recorded, err := os.ReadFile(list); err == nil {
// A retry repeats the list it already sent; another one would reorder what is now staged.
if !bytes.Equal(recorded, wanted) {
return fmt.Errorf("cache %d was already assembled from a different block list", id)
}
return nil
}
for i, blockID := range blockIDs {
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
if os.IsNotExist(err) {
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
return err
}
}
return nil
return os.WriteFile(list, wanted, 0o600)
}
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
@@ -143,9 +157,13 @@ func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
http.ServeFile(w, r, name)
}
func (s *Storage) Remove(id uint64) {
_ = os.Remove(s.filename(id))
_ = os.RemoveAll(s.tempDir(id))
// Remove deletes an entry's blob and any staged parts. It reports failure so the caller can
// keep the entry and retry, rather than dropping the only reference to bytes on disk.
func (s *Storage) Remove(id uint64) error {
if err := os.Remove(s.filename(id)); err != nil && !os.IsNotExist(err) {
return err
}
return os.RemoveAll(s.tempDir(id))
}
func (s *Storage) filename(id uint64) string {

View File

@@ -6,11 +6,12 @@ package artifacts
import (
"context"
"encoding/json"
"encoding/json/v2"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
@@ -50,65 +51,29 @@ type ResponseMessage struct {
Message string `json:"message"`
}
type WritableFile interface {
io.WriteCloser
}
type WriteFS interface {
OpenWritable(name string) (WritableFile, error)
OpenAppendable(name string) (WritableFile, error)
}
type readWriteFSImpl struct{}
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
return os.Open(name)
}
func (fwfs readWriteFSImpl) OpenWritable(name string) (WritableFile, error) {
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
return nil, err
}
return os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644)
}
func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
return nil, err
}
file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
_, err = file.Seek(0, io.SeekEnd)
if err != nil {
return nil, err
}
return file, nil
}
var gzipExtension = ".gz__"
func safeResolve(baseDir, relPath string) string {
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
}
func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
func writeJSON(w http.ResponseWriter, value any) {
data, err := json.Marshal(value)
if err != nil {
panic(err)
}
if _, err := w.Write(data); err != nil {
panic(err)
}
}
func uploads(router *httprouter.Router, baseDir string) {
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
runID := params.ByName("runId")
json, err := json.Marshal(FileContainerResourceURL{
writeJSON(w, FileContainerResourceURL{
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
@@ -122,67 +87,47 @@ func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
safeRunPath := safeResolve(baseDir, runID)
safePath := safeResolve(safeRunPath, itemPath)
file, err := func() (WritableFile, error) {
contentRange := req.Header.Get("Content-Range")
if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
return fsys.OpenAppendable(safePath)
}
return fsys.OpenWritable(safePath)
}()
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
panic(err)
}
flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
appendUpload := req.Header.Get("Content-Range")
if appendUpload != "" && !strings.HasPrefix(appendUpload, "bytes 0-") {
flags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
}
file, err := os.OpenFile(safePath, flags, 0o644)
if err != nil {
panic(err)
}
defer file.Close()
writer, ok := file.(io.Writer)
if !ok {
panic(errors.New("File is not writable"))
}
if req.Body == nil {
panic(errors.New("No body given"))
panic(errors.New("no body given"))
}
_, err = io.Copy(writer, req.Body)
_, err = io.Copy(file, req.Body)
if err != nil {
panic(err)
}
json, err := json.Marshal(ResponseMessage{
writeJSON(w, ResponseMessage{
Message: "success",
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
json, err := json.Marshal(ResponseMessage{
writeJSON(w, ResponseMessage{
Message: "success",
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
}
func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
func downloads(router *httprouter.Router, baseDir string) {
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
runID := params.ByName("runId")
safePath := safeResolve(baseDir, runID)
entries, err := fs.ReadDir(fsys, safePath)
entries, err := os.ReadDir(safePath)
if err != nil {
panic(err)
}
@@ -195,18 +140,10 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
})
}
json, err := json.Marshal(NamedFileContainerResourceURLResponse{
writeJSON(w, NamedFileContainerResourceURLResponse{
Count: len(list),
Value: list,
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
@@ -215,7 +152,7 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
var files []ContainerItem
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error {
err := filepath.WalkDir(safePath, func(path string, entry fs.DirEntry, err error) error {
if !entry.IsDir() {
rel, err := filepath.Rel(safePath, path)
if err != nil {
@@ -241,17 +178,9 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
panic(err)
}
json, err := json.Marshal(ContainerItemResponse{
writeJSON(w, ContainerItemResponse{
Value: files,
})
if err != nil {
panic(err)
}
_, err = w.Write(json)
if err != nil {
panic(err)
}
})
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
@@ -259,15 +188,16 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
safePath := safeResolve(baseDir, path)
file, err := fsys.Open(safePath)
file, err := os.Open(safePath)
if err != nil {
// try gzip file
file, err = fsys.Open(safePath + gzipExtension)
file, err = os.Open(safePath + gzipExtension)
if err != nil {
panic(err)
}
w.Header().Add("Content-Encoding", "gzip")
}
defer file.Close()
_, err = io.Copy(w, file)
if err != nil {
@@ -287,19 +217,18 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
router := httprouter.New()
logger.Debugf("Artifacts base path '%s'", artifactPath)
fsys := readWriteFSImpl{}
uploads(router, artifactPath, fsys)
downloads(router, artifactPath, fsys)
uploads(router, artifactPath)
downloads(router, artifactPath)
server := &http.Server{
Addr: fmt.Sprintf("%s:%s", addr, port),
Addr: net.JoinHostPort(addr, port),
ReadHeaderTimeout: 2 * time.Second,
Handler: router,
}
// run server
go func() {
logger.Infof("Start server on http://%s:%s", addr, port)
logger.Infof("Start server on http://%s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(err)
}

View File

@@ -7,8 +7,7 @@ package artifacts
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"encoding/json/v2"
"io"
"maps"
"net/http"
@@ -18,238 +17,18 @@ import (
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/julienschmidt/httprouter"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type writableMapFile struct {
fstest.MapFile
}
func (f *writableMapFile) Write(data []byte) (int, error) {
f.Data = data
return len(data), nil
}
func (f *writableMapFile) Close() error {
return nil
}
type writeMapFS struct {
fstest.MapFS
}
func (fsys writeMapFS) OpenWritable(name string) (WritableFile, error) {
file := &writableMapFile{
MapFile: fstest.MapFile{
Data: []byte("content2"),
},
}
fsys.MapFS[name] = &file.MapFile
return file, nil
}
func (fsys writeMapFS) OpenAppendable(name string) (WritableFile, error) {
file := &writableMapFile{
MapFile: fstest.MapFile{
Data: []byte("content2"),
},
}
fsys.MapFS[name] = &file.MapFile
return file, nil
}
func TestNewArtifactUploadPrepare(t *testing.T) {
assert := assert.New(t)
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
router := httprouter.New()
uploads(router, "artifact/server/path", writeMapFS{memfs})
req, _ := http.NewRequest(http.MethodPost, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.Fail("Wrong status")
}
response := FileContainerResourceURL{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal("http://localhost/upload/1", response.FileContainerResourceURL)
}
func TestArtifactUploadBlob(t *testing.T) {
assert := assert.New(t)
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
router := httprouter.New()
uploads(router, "artifact/server/path", writeMapFS{memfs})
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=some/file", strings.NewReader("content"))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.Fail("Wrong status")
}
response := ResponseMessage{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal("success", response.Message)
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
}
func TestFinalizeArtifactUpload(t *testing.T) {
assert := assert.New(t)
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
router := httprouter.New()
uploads(router, "artifact/server/path", writeMapFS{memfs})
req, _ := http.NewRequest(http.MethodPatch, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.Fail("Wrong status")
}
response := ResponseMessage{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal("success", response.Message)
}
func TestListArtifacts(t *testing.T) {
assert := assert.New(t)
memfs := fstest.MapFS(map[string]*fstest.MapFile{
"artifact/server/path/1/file.txt": {
Data: []byte(""),
},
})
router := httprouter.New()
downloads(router, "artifact/server/path", memfs)
req, _ := http.NewRequest(http.MethodGet, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
}
response := NamedFileContainerResourceURLResponse{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal(1, response.Count)
assert.Equal("file.txt", response.Value[0].Name)
assert.Equal("http://localhost/download/1", response.Value[0].FileContainerResourceURL)
}
func TestListArtifactContainer(t *testing.T) {
assert := assert.New(t)
memfs := fstest.MapFS(map[string]*fstest.MapFile{
"artifact/server/path/1/some/file": {
Data: []byte(""),
},
})
router := httprouter.New()
downloads(router, "artifact/server/path", memfs)
req, _ := http.NewRequest(http.MethodGet, "http://localhost/download/1?itemPath=some/file", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
}
response := ContainerItemResponse{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Len(response.Value, 1)
assert.Equal("some/file", response.Value[0].Path)
assert.Equal("file", response.Value[0].ItemType)
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
}
func TestDownloadArtifactFile(t *testing.T) {
assert := assert.New(t)
memfs := fstest.MapFS(map[string]*fstest.MapFile{
"artifact/server/path/1/some/file": {
Data: []byte("content"),
},
})
router := httprouter.New()
downloads(router, "artifact/server/path", memfs)
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/1/some/file", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
}
data := rr.Body.Bytes()
assert.Equal("content", string(data))
}
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
// and reachable everywhere, including when the CI job is itself a container.
func TestArtifactFlow(t *testing.T) {
artifactPath := t.TempDir()
// Serve the exact routes Serve() wires up, on a real loopback socket via httptest. httptest
// picks a free port and Close() tears the server down synchronously — avoiding both the
// port-rebind race and Serve()'s detached ListenAndServe goroutine, which logger.Fatal()s
// (process exit) on a bind error and can outlive the test's temp-dir cleanup.
router := httprouter.New()
fsys := readWriteFSImpl{}
uploads(router, artifactPath, fsys)
downloads(router, artifactPath, fsys)
uploads(router, artifactPath)
downloads(router, artifactPath)
server := httptest.NewServer(router)
defer server.Close()
@@ -257,8 +36,6 @@ func TestArtifactFlow(t *testing.T) {
client := server.Client()
client.Timeout = 5 * time.Second
// request performs one HTTP call and returns the status and body. The default transport adds
// Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain.
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
t.Helper()
req, err := http.NewRequest(method, rawURL, body)
@@ -289,6 +66,8 @@ func TestArtifactFlow(t *testing.T) {
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
require.Equal(t, http.StatusOK, status, string(data))
require.NoError(t, json.Unmarshal(data, &msg))
require.Equal(t, "success", msg.Message)
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
require.Equal(t, http.StatusOK, status, string(data))
@@ -314,6 +93,21 @@ func TestArtifactFlow(t *testing.T) {
require.Equal(t, content, string(stored))
})
t.Run("content-range", func(t *testing.T) {
const rawURL = "/upload/4?itemPath=chunks.txt"
status, data := request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("first"),
http.Header{"Content-Range": []string{"bytes 0-4/11"}})
require.Equal(t, http.StatusOK, status, string(data))
status, data = request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("-second"),
http.Header{"Content-Range": []string{"bytes 5-11/11"}})
require.Equal(t, http.StatusOK, status, string(data))
stored, err := os.ReadFile(filepath.Join(artifactPath, "4", "chunks.txt"))
require.NoError(t, err)
require.Equal(t, "first-second", string(stored))
})
t.Run("gzip-roundtrip", func(t *testing.T) {
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
@@ -365,9 +159,7 @@ func TestArtifactFlow(t *testing.T) {
})
}
func TestMkdirFsImplSafeResolve(t *testing.T) {
assert := assert.New(t)
func TestSafeResolve(t *testing.T) {
baseDir := "/foo/bar"
tests := map[string]struct {
@@ -385,97 +177,13 @@ func TestMkdirFsImplSafeResolve(t *testing.T) {
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
assert.Equal(tc.want, safeResolve(baseDir, tc.input))
require.Equal(t, tc.want, safeResolve(baseDir, tc.input))
})
}
}
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
fsys := readWriteFSImpl{}
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
w, err := fsys.OpenWritable(name)
require.NoError(t, err)
_, err = w.Write([]byte("first"))
require.NoError(t, err)
require.NoError(t, w.Close())
w, err = fsys.OpenAppendable(name)
require.NoError(t, err)
_, err = w.Write([]byte("-second"))
require.NoError(t, err)
require.NoError(t, w.Close())
got, err := os.ReadFile(name)
require.NoError(t, err)
require.Equal(t, "first-second", string(got))
w, err = fsys.OpenWritable(name)
require.NoError(t, err)
_, err = w.Write([]byte("replaced"))
require.NoError(t, err)
require.NoError(t, w.Close())
got, err = os.ReadFile(name)
require.NoError(t, err)
require.Equal(t, "replaced", string(got))
}
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
require.NotNil(t, cancel)
cancel()
}
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
assert := assert.New(t)
memfs := fstest.MapFS(map[string]*fstest.MapFile{
"artifact/server/path/some/file": {
Data: []byte("content"),
},
})
router := httprouter.New()
downloads(router, "artifact/server/path", memfs)
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/2/../../some/file", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
}
data := rr.Body.Bytes()
assert.Equal("content", string(data))
}
func TestArtifactUploadBlobUnsafePath(t *testing.T) {
assert := assert.New(t)
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
router := httprouter.New()
uploads(router, "artifact/server/path", writeMapFS{memfs})
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=../../some/file", strings.NewReader("content"))
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
assert.Fail("Wrong status")
}
response := ResponseMessage{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
panic(err)
}
assert.Equal("success", response.Message)
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
}

View File

@@ -1,60 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import "slices"
// CartesianProduct takes map of lists and returns list of unique tuples
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
listNames := make([]string, 0)
lists := make([][]any, 0)
for k, v := range mapOfLists {
listNames = append(listNames, k)
lists = append(lists, v)
}
listCart := cartN(lists...)
rtn := make([]map[string]any, 0)
for _, list := range listCart {
vMap := make(map[string]any)
for i, v := range list {
vMap[listNames[i]] = v
}
rtn = append(rtn, vMap)
}
return rtn
}
func cartN(a ...[]any) [][]any {
c := 1
for _, a := range a {
c *= len(a)
}
if c == 0 || len(a) == 0 {
return nil
}
p := make([][]any, c)
b := make([]any, c*len(a))
n := make([]int, len(a))
s := 0
for i := range p {
e := s + len(a)
pi := b[s:e]
p[i] = pi
s = e
for j, n := range n {
pi[j] = a[j][n]
}
for j := range slices.Backward(n) {
n[j]++
if n[j] < len(a[j]) {
break
}
n[j] = 0
}
}
return p
}

View File

@@ -1,43 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCartesianProduct(t *testing.T) {
assert := assert.New(t)
input := map[string][]any{
"foo": {1, 2, 3, 4},
"bar": {"a", "b", "c"},
"baz": {false, true},
}
output := CartesianProduct(input)
assert.Len(output, 24)
for _, v := range output {
assert.Len(v, 3)
assert.Contains(v, "foo")
assert.Contains(v, "bar")
assert.Contains(v, "baz")
}
input = map[string][]any{
"foo": {1, 2, 3, 4},
"bar": {},
"baz": {false, true},
}
output = CartesianProduct(input)
assert.Empty(output)
input = map[string][]any{}
output = CartesianProduct(input)
assert.Empty(output)
}

View File

@@ -54,22 +54,6 @@ func NewPipelineExecutor(executors ...Executor) Executor {
return rtn
}
// NewConditionalExecutor creates a new executor based on conditions
func NewConditionalExecutor(conditional Conditional, trueExecutor, falseExecutor Executor) Executor {
return func(ctx context.Context) error {
if conditional(ctx) {
if trueExecutor != nil {
return trueExecutor(ctx)
}
} else {
if falseExecutor != nil {
return falseExecutor(ctx)
}
}
return nil
}
}
// NewErrorExecutor creates a new executor that always errors out
func NewErrorExecutor(err error) Executor {
return func(ctx context.Context) error {
@@ -187,15 +171,8 @@ func (e Executor) Finally(finally Executor) Executor {
err := e(ctx)
err2 := finally(ctx)
if err2 != nil {
return fmt.Errorf("Error occurred running finally: %v (original error: %v)", err2, err)
return fmt.Errorf("error occurred running finally: %v (original error: %v)", err2, err)
}
return err
}
}
// Not return an inverted conditional
func (c Conditional) Not() Conditional {
return func(ctx context.Context) bool {
return !c(ctx)
}
}

View File

@@ -45,43 +45,6 @@ func TestNewWorkflow(t *testing.T) {
assert.Equal(2, runcount)
}
func TestNewConditionalExecutor(t *testing.T) {
assert := assert.New(t)
ctx := context.Background()
trueCount := 0
falseCount := 0
err := NewConditionalExecutor(func(ctx context.Context) bool {
return false
}, func(ctx context.Context) error {
trueCount++
return nil
}, func(ctx context.Context) error {
falseCount++
return nil
})(ctx)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(0, trueCount)
assert.Equal(1, falseCount)
err = NewConditionalExecutor(func(ctx context.Context) bool {
return true
}, func(ctx context.Context) error {
trueCount++
return nil
}, func(ctx context.Context) error {
falseCount++
return nil
})(ctx)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(1, trueCount)
assert.Equal(1, falseCount)
}
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
// block until wantActive are in flight so the peak is exact without sleeping, and later copies
// find the gate already open so the last one still finishes with no partner left.
@@ -223,10 +186,3 @@ func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
t.Fatalf("finally error = %q, want both cleanup and original error", err)
}
}
func TestConditionalNot(t *testing.T) {
cond := Conditional(func(context.Context) bool { return false })
if !cond.Not()(context.Background()) {
t.Fatal("inverted conditional should be true")
}
}

View File

@@ -6,24 +6,23 @@ package git
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"path"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/internal/pkg/lock"
"github.com/go-git/go-git/v5"
"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/http"
"github.com/mattn/go-isatty"
log "github.com/sirupsen/logrus"
)
@@ -32,11 +31,17 @@ var (
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
hexRefRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
LocalEnvVars = []string{ // `git rev-parse --local-env-vars`, inherited ones point git at another repository
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT", "GIT_OBJECT_DIRECTORY",
"GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE", "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS",
"GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
}
cloneLocks lock.Keyed[string] // key: clone target directory
ErrShortRef = errors.New("short SHA references are not supported")
ErrNoRepo = errors.New("unable to find git repo")
)
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
@@ -52,199 +57,98 @@ type Error struct {
commit string
}
func (e *Error) Error() string {
return e.err.Error()
}
func (e *Error) Unwrap() error {
return e.err
}
func (e *Error) Commit() string {
return e.commit
}
// goGitMu serializes go-git repository access across the process. go-git is not safe for
// concurrent use of the same repository (even read access decodes packfiles into shared
// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded
// operations are fast local reads; gitea runs one job per process, so the lock is effectively
// uncontended in production.
var goGitMu sync.Mutex
func (e *Error) Error() string { return e.err.Error() }
func (e *Error) Unwrap() error { return e.err }
func (e *Error) Commit() string { return e.commit }
// FindGitRevision get the current git revision
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
goGitMu.Lock()
defer goGitMu.Unlock()
return findGitRevision(ctx, file)
}
func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
func FindGitRevision(ctx context.Context, file string) (shortSHA, sha string, err error) {
logger := common.Logger(ctx)
gitDir, err := git.PlainOpenWithOptions(
file,
&git.PlainOpenOptions{
DetectDotGit: true,
EnableDotGitCommonDir: true,
},
)
sha, err = gitOutput(ctx, file, "rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
logger.WithError(err).Error("path", file, "not located inside a git repository")
return "", "", err
}
head, err := gitDir.Reference(plumbing.HEAD, true)
if err != nil {
return "", "", err
if len(sha) < 7 {
return "", "", errors.New("HEAD could not be resolved")
}
if head.Hash().IsZero() {
return "", "", errors.New("HEAD sha1 could not be resolved")
}
hash := head.Hash().String()
logger.Debugf("Found revision: %s", hash)
return hash[:7], strings.TrimSpace(hash), nil
logger.Debugf("Found revision: %s", sha)
return sha[:7], sha, nil
}
// FindGitRef get the current git ref
func FindGitRef(ctx context.Context, file string) (string, error) {
goGitMu.Lock()
defer goGitMu.Unlock()
logger := common.Logger(ctx)
logger.Debugf("Loading revision from git directory")
_, ref, err := findGitRevision(ctx, file)
_, revision, err := FindGitRevision(ctx, file)
if err != nil {
return "", err
}
logger.Debugf("HEAD points to '%s'", revision)
logger.Debugf("HEAD points to '%s'", ref)
// Prefer the git library to iterate over the references and find a matching tag or branch.
refTag := ""
refBranch := ""
repo, err := git.PlainOpenWithOptions(
file,
&git.PlainOpenOptions{
DetectDotGit: true,
EnableDotGitCommonDir: true,
},
)
refs, err := gitOutput(ctx, file, "for-each-ref", "--points-at=HEAD", "--format=%(refname)", "refs/tags", "refs/heads")
if err != nil {
return "", err
}
iter, err := repo.References()
if err != nil {
return "", err
}
// find the reference that matches the revision's has
err = iter.ForEach(func(r *plumbing.Reference) error {
/* tags and branches will have the same hash
* when a user checks out a tag, it is not mentioned explicitly
* in the go-git package, we must identify the revision
* then check if any tag matches that revision,
* if so then we checked out a tag
* else we look for branches and if matches,
* it means we checked out a branch
*
* If a branches matches first we must continue and check all tags (all references)
* in case we match with a tag later in the interation
*/
if r.Hash().String() == ref {
if r.Name().IsTag() {
refTag = r.Name().String()
}
if r.Name().IsBranch() {
refBranch = r.Name().String()
}
var tag, branch string
for ref := range strings.FieldsSeq(refs) {
switch {
case strings.HasPrefix(ref, "refs/tags/"):
tag = ref
case strings.HasPrefix(ref, "refs/heads/"):
branch = ref
}
// we found what we where looking for
if refTag != "" && refBranch != "" {
return storer.ErrStop
}
return nil
})
if err != nil {
return "", err
}
// order matters here see above comment.
if refTag != "" {
return refTag, nil
if tag != "" {
return tag, nil
}
if refBranch != "" {
return refBranch, nil
if branch != "" {
return branch, nil
}
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", ref)
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", revision)
}
// FindGithubRepo get the repo
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
goGitMu.Lock()
defer goGitMu.Unlock()
if remoteName == "" {
remoteName = "origin"
}
url, err := findGitRemoteURL(ctx, file, remoteName)
func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, error) {
url, err := findGitRemoteURL(ctx, file)
if err != nil {
return "", err
}
_, slug, err := findGitSlug(url, githubInstance)
return slug, err
_, slug := findGitSlug(url, githubInstance)
return slug, nil
}
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
repo, err := git.PlainOpenWithOptions(
file,
&git.PlainOpenOptions{
DetectDotGit: true,
EnableDotGitCommonDir: true,
},
)
func findGitRemoteURL(ctx context.Context, file string) (string, error) {
urls, err := gitOutput(ctx, file, "config", "--get-all", "remote.origin.url") // `remote get-url` applies insteadOf
if err != nil {
return "", err
}
remote, err := repo.Remote(remoteName)
if err != nil {
return "", err
if urls == "" {
return "", errors.New("remote 'origin' exists but has no URL")
}
if len(remote.Config().URLs) < 1 {
return "", fmt.Errorf("remote '%s' exists but has no URL", remoteName)
}
return remote.Config().URLs[0], nil
url, _, _ := strings.Cut(urls, "\n")
return url, nil
}
func findGitSlug(url, githubInstance string) (string, string, error) { //nolint:unparam // pre-existing issue from nektos/act
func findGitSlug(url, githubInstance string) (string, string) {
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
return "CodeCommit", matches[2], nil
return "CodeCommit", matches[2]
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
return "CodeCommit", matches[2], nil
return "CodeCommit", matches[2]
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
} else if githubInstance != "github.com" {
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
}
}
return "", url, nil
return "", url
}
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
@@ -264,85 +168,35 @@ type NewGitCloneExecutorInput struct {
Quiet bool
// For Gitea
InsecureSkipTLS bool
InsecureSkipTLS bool
ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories
}
// CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused.
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
r, err := git.PlainOpen(input.Dir)
// CloneIfRequired reports whether an existing local clone was reused.
func CloneIfRequired(ctx context.Context, input NewGitCloneExecutorInput, logger log.FieldLogger) (bool, error) {
var origin string
_, err := os.Stat(filepath.Join(input.Dir, ".git")) // without it git would discover a repository around Dir
if err == nil {
// Verify the cached clone still points to the resolved URL before reusing it.
remote, err := r.Remote("origin")
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
// Reuse existing clone
return r, true, nil
}
if err != nil {
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
} else if len(remote.Config().URLs) == 0 {
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
} else {
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
}
if err := os.RemoveAll(input.Dir); err != nil {
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
}
origin, err = findGitRemoteURL(ctx, input.Dir)
}
var progressWriter io.Writer
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
if entry, ok := logger.(*log.Entry); ok {
progressWriter = entry.WriterLevel(log.DebugLevel)
} else if lgr, ok := logger.(*log.Logger); ok {
progressWriter = lgr.WriterLevel(log.DebugLevel)
} else {
log.Errorf("Unable to get writer from logger (type=%T)", logger)
progressWriter = os.Stdout
}
if err == nil && origin == input.URL {
return true, nil
}
cloneOptions := git.CloneOptions{
URL: input.URL,
Progress: progressWriter,
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
if err == nil {
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, origin, input.URL)
} else if _, statErr := os.Stat(input.Dir); statErr == nil {
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
}
if input.Token != "" {
cloneOptions.Auth = &http.BasicAuth{
Username: "token",
Password: input.Token,
}
if err := os.RemoveAll(input.Dir); err != nil {
return false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
}
r, err = cloneAtDepth(ctx, input, cloneOptions, logger)
if err != nil {
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
return nil, false, err
if err := clone(ctx, input); err != nil {
return false, err
}
if err = os.Chmod(input.Dir, 0o755); err != nil {
return nil, false, err
if err := os.Chmod(input.Dir, 0o755); err != nil {
return false, err
}
return r, false, nil
}
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
fetchOptions.Force = true
pullOptions.Force = true
if token != "" {
auth := &http.BasicAuth{
Username: "token",
Password: token,
}
fetchOptions.Auth = auth
pullOptions.Auth = auth
}
return fetchOptions, pullOptions
return false, nil
}
// NewGitCloneExecutor creates an executor to clone git repos
@@ -355,188 +209,190 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
}
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
defer AcquireCloneLock(input.Dir)()
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
r, reused, err := CloneIfRequired(ctx, refName, input, logger)
reused, err := CloneIfRequired(ctx, input, logger)
if err != nil {
return err
}
isOfflineMode := input.OfflineMode
// fetch latest changes
fetchOptions, pullOptions := gitOptions(input.Token)
if input.InsecureSkipTLS { // For Gitea
fetchOptions.InsecureSkipTLS = true
pullOptions.InsecureSkipTLS = true
resolved, err := resolveRef(ctx, input.Dir, input.Ref)
if errors.Is(err, ErrShortRef) || (err != nil && input.OfflineMode) {
return err
}
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
shallow := isShallow(r)
if shallow {
fetchOptions.Depth = 1
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
fetchOptions.RefSpecs = []config.RefSpec{spec}
}
}
if !isOfflineMode {
err = r.Fetch(&fetchOptions)
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
if !input.OfflineMode && (err != nil || (reused && !isFullHash(input.Ref, resolved))) {
if err := fetch(ctx, input); err != nil {
return err
}
resolved, err = resolveRef(ctx, input.Dir, input.Ref)
}
var hash *plumbing.Hash
rev := plumbing.Revision(input.Ref)
if hash, err = r.ResolveRevision(rev); err != nil {
// ResolveRevision returns a nil hash on error, and a branch ref legitimately fails
// here (no local refs/heads/<ref>); the duck-typing below resolves it.
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
} else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
return &Error{
err: ErrShortRef,
commit: hash.String(),
}
}
// At this point we need to know if it's a tag or a branch
// And the easiest way to do it is duck typing
//
// If err is nil, it's a tag so let's proceed with that hash like we would if
// it was a sha
refType := "tag"
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
refType = "sha"
rev = plumbing.Revision(input.Ref)
} else {
refType = "branch"
rev = plumbing.Revision(rName)
}
}
if hash, err = r.ResolveRevision(rev); err != nil {
if err != nil {
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
return err
}
var w *git.Worktree
if w, err = r.Worktree(); err != nil {
return err
if _, err := runGit(ctx, input.Dir, nil, "checkout", "--force", "--detach", resolved); err != nil {
return fmt.Errorf("checkout %s: %w", resolved, err)
}
// If the hash resolved doesn't match the ref provided in a workflow then we're
// using a branch or tag ref, not a sha
//
// Repos on disk point to commit hashes, and need to checkout input.Ref before
// we try and pull down any changes
if hash.String() != input.Ref && refType == "branch" {
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
if err = w.Checkout(&git.CheckoutOptions{
Branch: sourceRef,
Force: true,
}); err != nil {
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
return err
}
}
reusedMsg := ""
switch {
case !isOfflineMode && !shallow:
// In shallow mode the depth-limited fetch above already advanced the ref.
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
logger.Debugf("Unable to pull %s: %v", refName, err)
}
case isOfflineMode && reused:
if input.OfflineMode && reused {
reusedMsg = " (reused in offline mode)"
}
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
if hash.String() != input.Ref && refType == "branch" {
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
if hash, err = r.ResolveRevision(rev); err != nil {
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
return err
}
}
if err = w.Checkout(&git.CheckoutOptions{
Hash: *hash,
Force: true,
}); err != nil {
logger.Errorf("Unable to checkout %s: %v", *hash, err)
return err
}
if err = w.Reset(&git.ResetOptions{
Mode: git.HardReset,
Commit: *hash,
}); err != nil {
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
return err
}
logger.Debugf("Checked out %s", input.Ref)
return nil
}
}
// cloneAtDepth clones input.URL into input.Dir using opts.
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
func clone(ctx context.Context, input NewGitCloneExecutorInput) error {
if input.Depth > 0 && isFullObjectID(input.Ref) {
if err := shallowPinnedClone(ctx, input); err == nil {
return nil
} else if err := removePartialClone(input.Dir); err != nil {
return err
}
}
args := []string{"clone", "--no-checkout"}
if input.Depth > 0 {
for _, refName := range []plumbing.ReferenceName{
plumbing.NewBranchReferenceName(input.Ref),
plumbing.NewTagReferenceName(input.Ref),
} {
shallowOpts := opts
shallowOpts.Depth = input.Depth
shallowOpts.SingleBranch = true
shallowOpts.ReferenceName = refName
shallowOpts.Tags = git.NoTags
args = append(args, "--no-local", "--depth", strconv.Itoa(input.Depth), "--no-tags")
if input.Ref != "HEAD" && !strings.HasPrefix(input.Ref, "refs/") {
args = append(args, "--branch", input.Ref)
}
}
args = append(args, input.URL, input.Dir)
if _, err := runGit(ctx, "", &input, args...); err == nil {
return nil
} else if input.Depth == 0 {
return err
}
if err := removePartialClone(input.Dir); err != nil {
return err
}
_, err := runGit(ctx, "", &input, "clone", "--no-checkout", input.URL, input.Dir)
return err
}
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
if err == nil {
return r, nil
}
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
func shallowPinnedClone(ctx context.Context, input NewGitCloneExecutorInput) error {
if _, err := runGit(ctx, "", &input, "init", "--object-format="+ObjectFormat(input.Ref), input.Dir); err != nil {
return err
}
if _, err := runGit(ctx, input.Dir, &input, "remote", "add", "origin", input.URL); err != nil {
return err
}
_, err := runGit(ctx, input.Dir, &input, "fetch", "--depth", strconv.Itoa(input.Depth), "--no-tags", "origin", "+"+input.Ref+":refs/pinned/"+input.Ref)
return err
}
func fetch(ctx context.Context, input NewGitCloneExecutorInput) error {
args := []string{"fetch", "--force", "--prune"}
if _, err := os.Stat(filepath.Join(input.Dir, ".git", "shallow")); err == nil {
args = append(args, "--depth", "1", "--no-tags")
}
_, err := runGit(ctx, input.Dir, &input, append(args, "origin", "+"+input.Ref+":"+cachedRef(input.Ref))...) // git picks the remote ref, a tag before a branch
return err
}
func cachedRef(ref string) string {
return fmt.Sprintf("refs/runner/%x", sha256.Sum256([]byte(ref)))
}
func resolveRef(ctx context.Context, dir, ref string) (string, error) {
if !isFullObjectID(ref) {
for _, name := range []string{cachedRef(ref), "refs/tags/" + ref, "refs/remotes/origin/" + ref} {
if sha, err := gitOutput(ctx, dir, "rev-parse", "--verify", name+"^{commit}"); err == nil {
return sha, nil
}
}
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
}
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
sha, err := gitOutput(ctx, dir, "rev-parse", "--verify", ref+"^{commit}")
if err == nil && hexRefRegex.MatchString(ref) && len(sha) != len(ref) {
return "", &Error{err: ErrShortRef, commit: sha}
}
return sha, err
}
// isShallow reports whether the local repository was cloned with a limited depth.
func isShallow(r *git.Repository) bool {
shallows, err := r.Storer.Shallow()
return err == nil && len(shallows) > 0
func isFullObjectID(ref string) bool {
return ObjectFormat(ref) != ""
}
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
tagRef := plumbing.NewTagReferenceName(ref)
if _, err := r.Reference(tagRef, false); err == nil {
return config.RefSpec(fmt.Sprintf("+%s:%s", tagRef, tagRef)), true
}
remoteRef := plumbing.NewRemoteReferenceName("origin", ref)
if _, err := r.Reference(remoteRef, false); err == nil {
branchRef := plumbing.NewBranchReferenceName(ref)
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
}
return "", false
func isFullHash(ref, resolved string) bool {
return isFullObjectID(ref) && len(ref) == len(resolved)
}
// ObjectFormat returns the object format of a full commit hash, empty for anything else.
func ObjectFormat(sha string) string {
switch {
case !hexRefRegex.MatchString(sha):
return ""
case len(sha) == 40:
return "sha1"
case len(sha) == 64:
return "sha256"
}
return ""
}
func removePartialClone(dir string) error {
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("remove partial clone %s: %w", dir, err)
}
return nil
}
func gitOutput(ctx context.Context, dir string, args ...string) (string, error) {
output, err := runGit(ctx, dir, nil, args...)
return strings.TrimSpace(string(output)), err
}
func RemoteConfig(token string, insecureSkipTLS bool) []string {
var config []string
if insecureSkipTLS {
config = append(config, "http.sslVerify=false")
}
if token != "" {
config = append(config, "http.extraHeader=", "http.extraHeader=Authorization: Basic "+base64.StdEncoding.EncodeToString([]byte("token:"+token)))
}
return config
}
// ConfigEnv applies `-c` style entries through the environment, keeping secrets out of argv.
func ConfigEnv(config ...string) map[string]string {
env := map[string]string{"GIT_CONFIG_COUNT": strconv.Itoa(len(config))}
for i, entry := range config {
key, value, _ := strings.Cut(entry, "=")
env["GIT_CONFIG_KEY_"+strconv.Itoa(i)] = key
env["GIT_CONFIG_VALUE_"+strconv.Itoa(i)] = value
}
return env
}
func runGit(ctx context.Context, dir string, input *NewGitCloneExecutorInput, args ...string) ([]byte, error) {
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
if input != nil {
config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...)
if input.ClientCertFile != "" {
config = append(config, "http.sslCert="+input.ClientCertFile, "http.sslKey="+input.ClientKeyFile)
}
}
if dir != "" {
args = append([]string{"-C", dir}, args...)
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Env = slices.DeleteFunc(os.Environ(), func(entry string) bool {
name, _, _ := strings.Cut(entry, "=")
return slices.Contains(LocalEnvVars, name)
})
for key, value := range ConfigEnv(config...) {
cmd.Env = append(cmd.Env, key+"="+value)
}
cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0")
if runtime.GOOS != "windows" {
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } // lets git remove its lock files
}
cmd.WaitDelay = time.Second
var stderr strings.Builder
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
return output, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
}
return output, nil
}

View File

@@ -6,12 +6,16 @@ package git
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
@@ -45,9 +49,7 @@ func TestFindGitSlug(t *testing.T) {
}
for _, tt := range slugTests {
provider, slug, err := findGitSlug(tt.url, "github.com")
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
provider, slug := findGitSlug(tt.url, "github.com")
assert.Equal(tt.provider, provider)
assert.Equal(tt.slug, slug)
}
@@ -81,48 +83,37 @@ func cleanGitHooks(dir string) error {
return nil
}
func TestFindGitRemoteURL(t *testing.T) {
assert := assert.New(t)
func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
t.Parallel()
basedir := t.TempDir()
err := gitCmd("init", basedir)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
err = cleanGitHooks(basedir)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
remoteURL := "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name"
err = gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
u, err := findGitRemoteURL(context.Background(), basedir, "origin")
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(remoteURL, u)
remoteURL = "git@github.com/AwesomeOwner/MyAwesomeRepo.git"
err = gitCmd("-C", basedir, "remote", "add", "upstream", remoteURL)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
u, err = findGitRemoteURL(context.Background(), basedir, "upstream")
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(remoteURL, u)
}
func TestFindGithubRepoUsesOriginAndCustomRemote(t *testing.T) {
basedir := t.TempDir()
require.NoError(t, gitCmd("init", basedir))
const remoteURL = "https://github.com/owner/repo.git"
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir))
require.NoError(t, cleanGitHooks(basedir))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", "https://github.com/owner/repo.git"))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "ghe", "git@git.example.com:team/project.git"))
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
shortSHA, sha, err := FindGitRevision(context.Background(), basedir)
require.NoError(t, err)
require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha)
require.Len(t, sha, 64)
require.Equal(t, sha[:7], shortSHA)
ref, err := FindGitRef(context.Background(), basedir)
require.NoError(t, err)
require.Equal(t, "refs/heads/main", ref)
url, err := findGitRemoteURL(context.Background(), basedir)
require.NoError(t, err)
require.Equal(t, remoteURL, url)
slug, err := FindGithubRepo(context.Background(), basedir, "github.com")
require.NoError(t, err)
require.Equal(t, "owner/repo", slug)
slug, err = FindGithubRepo(context.Background(), basedir, "git.example.com", "ghe")
require.NoError(t, err)
require.Equal(t, "team/project", slug)
}
func TestGitFindRef(t *testing.T) {
t.Parallel()
basedir := t.TempDir()
for name, tt := range map[string]struct {
@@ -209,7 +200,9 @@ func TestGitCloneExecutor(t *testing.T) {
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755))
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh"))
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
@@ -223,6 +216,13 @@ func TestGitCloneExecutor(t *testing.T) {
require.NoError(t, err)
fullSha := strings.TrimSpace(string(out))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4]))
hostConfig := filepath.Join(t.TempDir(), "gitconfig")
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n[url \""+remoteDir+"\"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
t.Setenv("GIT_CONFIG_GLOBAL", hostConfig)
t.Setenv("GIT_DIR", t.TempDir())
for name, tt := range map[string]struct {
Err error
Ref string
@@ -243,26 +243,42 @@ func TestGitCloneExecutor(t *testing.T) {
Err: &Error{ErrShortRef, fullSha},
Ref: fullSha[:7],
},
"HEAD": {
Ref: "HEAD",
},
"hex branch named like its own commit": {
Ref: fullSha[:4],
},
} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir,
URL: "https://example.invalid/action",
Ref: tt.Ref,
Dir: t.TempDir(),
Dir: dir,
})
err := clone(context.Background())
if tt.Err != nil {
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.Err, err)
} else {
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.Err, clone(context.Background()), "a retry on the cache keeps the error")
return
}
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
marker := filepath.Join(dir, "marker")
require.NoError(t, os.WriteFile(marker, nil, 0o644))
require.NoError(t, clone(context.Background()))
assert.FileExists(t, marker, "an insteadOf rewrite must not evict the cache")
script, err := os.ReadFile(filepath.Join(dir, "action.sh"))
require.NoError(t, err)
assert.Equal(t, "#!/bin/sh\necho hi\n", string(script), "host core.autocrlf must not convert action files")
})
}
}
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
t.Parallel()
createRemote := func(message string) string {
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
@@ -286,6 +302,11 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
Dir: cacheDir,
})(t.Context()))
nested := filepath.Join(cacheDir, "nested")
require.NoError(t, os.Mkdir(nested, 0o755))
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: oldRemoteDir, Ref: "main", Dir: nested})(t.Context()))
assert.DirExists(t, filepath.Join(nested, ".git"), "an empty cache dir inside a clone of the same URL gets its own repository")
markerPath := filepath.Join(cacheDir, "stale-marker")
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
@@ -295,7 +316,7 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
Dir: cacheDir,
})(t.Context()))
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
originURL, err := findGitRemoteURL(t.Context(), cacheDir)
require.NoError(t, err)
assert.Equal(t, newRemoteDir, originURL)
@@ -307,61 +328,36 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
}
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
// Create a bare "remote" repo with an initial commit on main and a feature branch.
func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) {
t.Parallel()
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
// We need a working clone to push commits from.
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
// Create a feature branch (simulates refs/pull/N/head).
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "feature"))
// First clone via the executor — should succeed and cache the repo.
cloneDir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir,
Ref: "main",
Dir: cloneDir,
})
dir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "main", Dir: dir})
require.NoError(t, clone(context.Background()))
// Now force-push the feature branch to a non-fast-forward commit (simulates
// a PR rebase). This makes refs/heads/feature non-fast-forward.
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
require.NoError(t, gitCmd("-C", workDir, "branch", "-D", "feature"))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
require.NoError(t, gitCmd("-C", workDir, "reset", "--hard", "HEAD~1"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "feature"))
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "main"))
require.NoError(t, clone(context.Background()), "a non-fast-forward ref must update")
assert.Equal(t, "feature-rewritten", gitHeadSubject(t, dir))
// Also advance main so we can verify the clone picks up the new commit.
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "second"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
// Second clone to the same directory — before the fix this returned ErrForceNeeded
// and left the working tree at the old commit.
err := clone(context.Background())
require.NoError(t, err, "fetch with non-fast-forward refs must not fail when Force=true")
// Verify the working tree was actually updated to the latest main commit.
out, err := exec.Command("git", "-C", cloneDir, "log", "--oneline", "-1", "--format=%s").Output()
require.NoError(t, err)
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
tagClone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "v9", Dir: t.TempDir()})
require.Error(t, tagClone(context.Background()))
require.NoError(t, gitCmd("-C", workDir, "tag", "v9"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v9"))
require.NoError(t, tagClone(context.Background()), "a tag published after a failed lookup must be fetched")
}
func TestGitCloneExecutorOfflineMode(t *testing.T) {
t.Parallel()
// Build a local "remote" with a single commit on main.
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
@@ -408,6 +404,7 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
}
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
t.Parallel()
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
workDir := t.TempDir()
@@ -446,6 +443,7 @@ func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
}
func TestGitCloneExecutorShallow(t *testing.T) {
t.Parallel()
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
@@ -456,7 +454,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
}
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
@@ -482,34 +480,105 @@ func TestGitCloneExecutorShallow(t *testing.T) {
assert.Equal(t, "c3", gitHeadSubject(t, dir))
})
t.Run("SHA falls back to a full clone", func(t *testing.T) {
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
remote := filepath.Join(t.TempDir(), "remote.git")
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
return remote
}
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
})(t.Context()))
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
})
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
remote := unadvertisedRemote(t, true)
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
})(t.Context()))
cloneAt := func(ref string) error {
return NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remote, Ref: ref, Dir: dir, Depth: 1,
})(t.Context())
}
require.NoError(t, cloneAt(unadvertisedSHA))
assert.FileExists(t, shallowMarker(dir))
assert.Equal(t, 1, gitRevCount(t, dir))
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
olderSHA := gitRevParse(t, workDir, "HEAD~2")
require.NoError(t, cloneAt(olderSHA))
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
require.NoError(t, os.RemoveAll(remote))
require.NoError(t, cloneAt(olderSHA))
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
})
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
dir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: "deadbeef", Dir: dir, Depth: 1,
})
require.NoError(t, clone(t.Context()))
require.Equal(t, "c3", gitHeadSubject(t, dir))
// Advance main on the remote, then reuse the existing shallow clone.
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
})(t.Context()))
require.NoError(t, clone(t.Context()))
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
assert.Equal(t, 1, gitRevCount(t, dir))
})
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
remote := filepath.Join(t.TempDir(), "remote.git")
require.NoError(t, gitCmd("init", "--bare", "--object-format=sha256", "--initial-branch=main", remote))
work := t.TempDir()
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", work))
require.NoError(t, gitCmd("-C", work, "remote", "add", "origin", remote))
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
require.NoError(t, gitCmd("-C", work, "push", "origin", "main"))
sha := gitRevParse(t, work, "HEAD")
dir := t.TempDir()
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
require.NoError(t, clone(t.Context()))
assert.FileExists(t, shallowMarker(dir))
require.ErrorIs(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha[:40], Dir: dir, Depth: 1, OfflineMode: true})(t.Context()), ErrShortRef)
require.NoError(t, os.RemoveAll(remote))
require.NoError(t, clone(t.Context()))
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
})
}
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
t.Parallel()
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "pinned"))
pinned := gitRevParse(t, workDir, "HEAD")
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
})(t.Context()))
assert.Equal(t, pinned, gitRevParse(t, dir, "HEAD"))
})
}
}
func gitRevParse(t *testing.T, dir, rev string) string {
@@ -610,3 +679,50 @@ func TestAcquireCloneLock(t *testing.T) {
}
})
}
// An unresponsive remote must not pin a job: the refresh has to be interruptible.
func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
t.Parallel()
block := make(chan struct{})
reached := make(chan struct{})
var once sync.Once
var authorization string
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
once.Do(func() {
authorization = request.Header.Get("Authorization")
close(reached)
})
<-block
}))
t.Cleanup(func() {
close(block)
server.Close()
})
dir := filepath.Join(t.TempDir(), "cached-action")
require.NoError(t, gitCmd("init", dir))
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
done := make(chan error, 1)
go func() {
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir, Token: "secret"})(ctx)
}()
select {
case <-reached:
case <-time.After(10 * time.Second):
t.Fatal("the executor never reached the remote")
}
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), authorization)
cancel()
select {
case err := <-done:
require.Error(t, err)
case <-time.After(10 * time.Second):
t.Fatal("fetch ignored context cancellation")
}
}

View File

@@ -46,14 +46,14 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
line, err := pBuf.ReadString('\n')
w, _ := lw.buffer.WriteString(line)
written += w
if err == nil {
lw.handleLine(lw.buffer.String())
lw.buffer.Reset()
} else if err == io.EOF {
break
} else {
if err != nil {
if err == io.EOF {
break
}
return written, err
}
lw.handleLine(lw.buffer.String())
lw.buffer.Reset()
}
return written, nil

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
}

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"},
}))
}

View File

@@ -6,12 +6,18 @@ package container
import (
"context"
"errors"
"fmt"
"io"
"path"
"strings"
"sync"
"sync/atomic"
"gitea.com/gitea/runner/act/common"
"github.com/docker/go-connections/nat"
"github.com/moby/moby/api/types/container"
)
// ExitCodeError reports a non-zero process exit code from a container command.
@@ -23,26 +29,27 @@ func (e ExitCodeError) Error() string {
// NewContainerInput the input for the New function
type NewContainerInput struct {
Image string
Username string
Password string
Entrypoint []string
Cmd []string
WorkingDir string
Env []string
Binds []string
Mounts map[string]string
Name string
Stdout io.Writer
Stderr io.Writer
NetworkMode string
Privileged bool
UsernsMode string
Platform string
Options string
NetworkAliases []string
ExposedPorts nat.PortSet
PortBindings nat.PortMap
Image string
Username string
Password string
Entrypoint []string
Cmd []string
WorkingDir string
Env []string
Binds []string
Mounts map[string]string
Name string
Stdout io.Writer
Stderr io.Writer
NetworkMode string
Privileged bool
UsernsMode string
Platform string
RunnerOptions string // container options the runner was configured with, trusted
WorkflowOptions string // container options the workflow asked for, untrusted
NetworkAliases []string
ExposedPorts nat.PortSet
PortBindings nat.PortMap
// Gitea specific
AutoRemove bool
@@ -57,14 +64,72 @@ type FileEntry struct {
Body string
}
// Container and healthcheck states, as plain strings so a caller of Info needs no docker
// SDK of its own.
const (
StateRunning = string(container.StateRunning)
HealthNone = string(container.NoHealthcheck)
HealthStarting = string(container.Starting)
HealthHealthy = string(container.Healthy)
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")
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
type DockerProxy struct {
Socket string
close func(context.Context) error
closeOnce sync.Once
closeErr error
mounts atomic.Value
}
func (p *DockerProxy) SetMounts(mounts map[string]string) {
p.mounts.Store(mounts)
}
func (p *DockerProxy) Close(ctx context.Context) error {
p.closeOnce.Do(func() { p.closeErr = p.close(ctx) })
return p.closeErr
}
// Info is a snapshot of a container, as of one inspect.
type Info struct {
ID string
State string // the docker container state: "created", "running", "exited", ...
ExitCode int
Health string // one of the Health* constants
// HealthOutput is the last healthcheck probe's output.
HealthOutput string
Ports map[string]string // container port ("5432") to the host port it is published on
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
ConnectToNetwork(name string) common.Executor
Copy(destPath string, files ...*FileEntry) common.Executor
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
Inspect(ctx context.Context) (*Info, error)
DumpLogs(ctx context.Context) error
Pull(forcePull bool) common.Executor
Start(attach bool) common.Executor
Exec(command []string, env map[string]string, user, workdir string) common.Executor

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.
@@ -17,8 +18,7 @@
package container
import (
"bytes"
"encoding/json"
"encoding/json/jsontext"
"errors"
"fmt"
"net"
@@ -153,6 +153,7 @@ type containerOptions struct {
runtime string
autoRemove bool
init bool
umask opts.UmaskOpt
annotations *opts.MapOpts
Image string
@@ -327,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"})
@@ -351,7 +355,7 @@ type containerConfig struct {
// parse parses the args for the specified command and generates a Config,
// a HostConfig and returns them with the specified command.
// If the specified args are not valid, it will return an error.
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { //nolint:gocyclo // verbatim copy from docker/cli
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { //nolint:gocyclo,unparam // verbatim copy from docker/cli
var (
attachStdin = copts.attach.Get("stdin")
attachStdout = copts.attach.Get("stdout")
@@ -722,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() {
@@ -959,11 +964,11 @@ func parseSecurityOpts(securityOpts []string) ([]string, error) {
if err != nil {
return securityOpts, fmt.Errorf("opening seccomp profile (%s) failed: %w", v, err)
}
var b bytes.Buffer
if err := json.Compact(&b, f); err != nil {
profile := jsontext.Value(f)
if err := profile.Compact(); err != nil {
return securityOpts, fmt.Errorf("compacting json for seccomp profile (%s) failed: %w", v, err)
}
securityOpts[key] = "seccomp=" + b.String()
securityOpts[key] = "seccomp=" + string(profile)
}
}
}

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

View File

@@ -10,7 +10,9 @@ import (
"fmt"
"io"
"slices"
"strings"
"github.com/docker/cli/opts"
"github.com/kballard/go-shellquote"
"github.com/spf13/pflag"
)
@@ -51,15 +53,16 @@ func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
flags.SetOutput(io.Discard)
copts := addFlags(flags)
copts.env = opts.NewListOpts(validateEnv) // addFlags registered this field's address, so the swap takes effect
cf := registerCreateFlags(flags)
args, err := shellquote.Split(options)
if err != nil {
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
return flags, copts, cf, fmt.Errorf("cannot split container options: '%s': '%w'", options, err)
}
if err := flags.Parse(args); err != nil {
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
return flags, copts, cf, fmt.Errorf("cannot parse container options: '%s': '%w'", options, err)
}
return flags, copts, cf, nil
@@ -73,6 +76,30 @@ func createFlagsFromOptions(options string) *createFlags {
return cf
}
// validateEnv is opts.ValidateEnv without its lookup of a bare name in the runner's environment.
func validateEnv(val string) (string, error) {
if name, _, _ := strings.Cut(val, "="); name == "" {
return "", errors.New("invalid environment variable: " + val)
}
return val, nil
}
// rejectHostReadingOptions refuses the flags naming files that are read here, on the
// runner, rather than in the container.
func rejectHostReadingOptions(options string) error {
flags, _, _, err := parseContainerOptions(options)
if err != nil {
return err
}
for _, name := range []string{"env-file", "label-file"} {
if flags.Changed(name) {
return fmt.Errorf("container option --%s reads files from the runner and is not allowed in a workflow", name)
}
}
return nil
}
func (cf *createFlags) validate() error {
if !slices.Contains(pullPolicies, cf.pull) {
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)

View File

@@ -50,13 +50,13 @@ func TestCreateFlagsValidate(t *testing.T) {
}
func TestNewContainerAppliesCreateFlags(t *testing.T) {
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
input := &NewContainerInput{Platform: "linux/amd64", RunnerOptions: "--pull never", WorkflowOptions: "--platform linux/arm64"}
cr, ok := NewContainer(input).(*containerReference)
require.True(t, ok)
assert.Equal(t, "linux/arm64", input.Platform)
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
kept := &NewContainerInput{Platform: "linux/amd64", RunnerOptions: "--privileged"}
NewContainer(kept)
assert.Equal(t, "linux/amd64", kept.Platform)
}

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 {

View File

@@ -8,7 +8,7 @@ package container
import (
"bufio"
"encoding/json"
"encoding/json/v2"
"errors"
"io"
@@ -20,8 +20,8 @@ type dockerMessage struct {
Stream string `json:"stream"`
Error string `json:"error"`
ErrorDetail struct {
Message string
}
Message string `json:"message"`
} `json:"errorDetail"`
Status string `json:"status"`
Progress string `json:"progress"`
}
@@ -60,15 +60,16 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
return errors.New(msg.ErrorDetail.Message)
}
if msg.Status != "" {
switch {
case msg.Status != "":
if msg.Progress != "" {
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
} else {
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
}
} else if msg.Stream != "" {
case msg.Stream != "":
writeLog(logger, isError, "%s", msg.Stream)
} else {
default:
writeLog(logger, false, "Unable to handle line: %s", string(line))
}
}

View File

@@ -10,21 +10,20 @@ import (
"context"
"errors"
"fmt"
"net/netip"
"strings"
"time"
"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
@@ -32,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),
})
@@ -51,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 {
@@ -59,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
@@ -78,45 +87,54 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
return err
}
defer cli.Close()
return createDockerNetwork(ctx, cli, name, opts)
}
}
// Only create the network if it doesn't exist
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
if err != nil {
func createDockerNetwork(ctx context.Context, cli client.APIClient, name string, opts NewDockerNetworkCreateExecutorInput) error {
// Only create the network if it doesn't exist
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
if err != nil {
return err
}
var options map[string]string
// For Gitea, reduce log noise
// common.Logger(ctx).Debugf("%v", networks)
for _, n := range networks.Items {
if n.Name == name {
common.Logger(ctx).Debugf("Network %v exists", name)
return nil
}
// the daemon's mtu only reaches its default bridge, Docker's default is left out to keep default-network-opts
if mtu := n.Options["com.docker.network.driver.mtu"]; n.Name == network.NetworkBridge && mtu != "" && mtu != "1500" {
options = map[string]string{"com.docker.network.driver.mtu": mtu}
}
}
for i := range networkCreateAttempts {
if i > 0 {
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
}
}
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
Driver: "bridge",
Scope: "local",
EnableIPv4: opts.EnableIPv4,
EnableIPv6: opts.EnableIPv6,
Options: options,
Labels: runnerLabels(opts.RunnerUUID),
}); err == nil {
return nil
}
if !isAddressPoolExhausted(err) {
return err
}
// For Gitea, reduce log noise
// common.Logger(ctx).Debugf("%v", networks)
for _, n := range networks.Items {
if n.Name == name {
common.Logger(ctx).Debugf("Network %v exists", name)
return nil
}
}
for i := range networkCreateAttempts {
if i > 0 {
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
}
}
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
Driver: "bridge",
Scope: "local",
EnableIPv4: opts.EnableIPv4,
EnableIPv6: opts.EnableIPv6,
Labels: runnerLabels(opts.RunnerUUID),
}); err == nil {
return nil
}
if !isAddressPoolExhausted(err) {
return err
}
}
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
}
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
}
func runnerLabels(runnerUUID string) map[string]string {
@@ -134,6 +152,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)
@@ -172,3 +234,49 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
return errors.Join(errs...)
}
}
// 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 isolatedCacheContainer(ctx, cli, addr, jobNetwork)
}
func isolatedCacheContainer(ctx context.Context, cli client.APIClient, addr netip.Addr, jobNetwork string) (string, error) {
if jobNetwork == "host" || strings.HasPrefix(jobNetwork, "container:") {
return "", nil
}
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{})
if err != nil {
return "", err
}
var holderID, holderContainer string
for _, summary := range containers.Items {
if summary.NetworkSettings == nil {
continue
}
for _, endpoint := range summary.NetworkSettings.Networks {
if endpoint != nil && (endpoint.IPAddress == addr || endpoint.GlobalIPv6Address == addr) {
holderID = endpoint.NetworkID
holderContainer = summary.ID[:12]
}
}
}
if holderID == "" {
return "", nil
}
holder, err := cli.NetworkInspect(ctx, holderID, client.NetworkInspectOptions{})
if err != nil || holder.Network.Driver != "bridge" {
return "", err
}
if jobNetwork != "" {
job, err := cli.NetworkInspect(ctx, jobNetwork, client.NetworkInspectOptions{})
if err != nil || job.Network.ID == holderID {
return "", err
}
}
return holderContainer, nil
}

View File

@@ -6,25 +6,116 @@ package container
import (
"context"
"errors"
"net/netip"
"testing"
"time"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
mobyclient "github.com/moby/moby/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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},
}},
}}}, nil)
for ref, inspected := range map[string]network.Network{
"c0ffee": {ID: "c0ffee", Name: "compose", Driver: "bridge"},
"beef": {ID: "beef", Name: "lan", Driver: "macvlan"},
"c0f": {ID: "c0ffee"},
"jobs": {ID: "f00d"},
} {
client.On("NetworkInspect", ctx, ref, mobyclient.NetworkInspectOptions{}).
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: inspected}}, nil)
}
check := func(addr netip.Addr, jobNetwork, want string) {
got, err := isolatedCacheContainer(ctx, client, addr, jobNetwork)
require.NoError(t, err)
assert.Equal(t, want, got, jobNetwork)
}
check(cache, "", "0123456789ab")
check(cache, "jobs", "0123456789ab")
check(cache, "c0f", "")
check(cache, "host", "")
check(lan, "", "")
check(netip.MustParseAddr("10.0.0.5"), "", "")
}
func TestIsAddressPoolExhausted(t *testing.T) {
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
}
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
// 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 TestCreateDockerNetworkInheritsDaemonMTU(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
bridgeOptions, want map[string]string
}{
{
map[string]string{"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1", "com.docker.network.driver.mtu": "1450"},
map[string]string{"com.docker.network.driver.mtu": "1450"},
},
{map[string]string{"com.docker.network.driver.mtu": "1500"}, nil},
} {
client := &mockDockerClient{}
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{}).
Return(mobyclient.NetworkListResult{Items: []network.Summary{{Name: network.NetworkBridge, Options: tc.bridgeOptions}}}, nil)
client.On("NetworkCreate", ctx, "job-network", mobyclient.NetworkCreateOptions{Driver: "bridge", Scope: "local", Options: tc.want}).
Return(mobyclient.NetworkCreateResult{}, nil)
require.NoError(t, createDockerNetwork(ctx, client, "job-network", NewDockerNetworkCreateExecutorInput{}))
client.AssertExpectations(t)
}
}
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)
@@ -32,19 +123,32 @@ func TestRemoveOrphanNetworks(t *testing.T) {
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
{Network: network.Network{ID: "orphan"}},
{Network: network.Network{ID: "busy"}},
{Network: network.Network{ID: "starting"}},
{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)
}

View File

@@ -0,0 +1,587 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"mime"
"net"
"net/http"
"net/http/httputil"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/client"
)
const (
jobLabel = "com.gitea.runner.job"
maxCreateBody = 8 << 20
dockerProxyProbeTimeout = 5 * time.Second
)
var (
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
)
func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
if host := os.Getenv("DOCKER_HOST"); runtime.GOOS != "linux" || host != "" && !strings.HasPrefix(host, "unix://") {
return nil
}
probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
defer cancel()
cli, err := GetDockerClient(probeCtx)
if err != nil {
return nil
}
defer cli.Close()
daemonSocket, ok := strings.CutPrefix(cli.DaemonHost(), "unix://")
if !ok {
return nil
}
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 {
return nil
}
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)
}
}
if err != nil {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil
}
if !seen {
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
return nil
}
if ctx.Err() != nil {
return nil
}
proxy, err := StartDockerProxy(daemonSocket, dir, job)
if err != nil {
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
return nil
}
proxy.Socket = daemonDir + strings.TrimPrefix(proxy.Socket, dir)
return proxy
}
// runnerContainerWorkdir looks the runner's container up by hostname to find the daemon's path to its working directory.
func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir, daemonDir string) {
workdir, err := os.Getwd()
hostname, hostnameErr := os.Hostname()
if err != nil || hostnameErr != nil {
return "", ""
}
self, err := cli.ContainerInspect(ctx, hostname, client.ContainerInspectOptions{})
if err != nil {
return "", ""
}
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
}
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
}
defer func() {
if err := os.Remove(marker.Name()); err != nil {
common.Logger(ctx).Warnf("removing the docker proxy probe marker failed: %v", err)
}
}()
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
}
if len(images.Items) == 0 {
return false, errors.New("no image available for the docker proxy probe")
}
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: 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) {
return false, nil
}
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 _, 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 seen, err
}
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
info, err := os.Stat(daemonSocket)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSocket == 0 {
return nil, errors.New("docker daemon path is not a Unix socket")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
instance, err := os.MkdirTemp(dir, "p-")
if err != nil {
return nil, err
}
socket := filepath.Join(instance, "docker.sock")
listener, err := net.Listen("unix", socket)
if err != nil {
return nil, errors.Join(err, os.RemoveAll(instance))
}
if err := copyDockerSocketPermissions(daemonSocket, socket, info); err != nil {
return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance))
}
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
}
transport := &http.Transport{DialContext: dial}
forward := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.Out.URL.Scheme = "http"
r.Out.URL.Host = "docker"
},
Transport: transport,
}
proxy := &DockerProxy{Socket: socket}
streams, cancelStreams := context.WithCancel(context.Background())
creates, cancelCreates := context.WithCancel(context.Background())
var admission sync.Mutex
var handlers sync.WaitGroup
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, ConnContext: func(ctx context.Context, conn net.Conn) context.Context {
return context.WithValue(ctx, dockerProxyConnKey{}, conn)
}, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
admission.Lock()
if streams.Err() != nil {
admission.Unlock()
http.Error(w, "docker proxy is closing", http.StatusServiceUnavailable)
return
}
handlers.Add(1)
admission.Unlock()
defer handlers.Done()
creating := r.Method == http.MethodPost && createPath.MatchString(r.URL.Path)
parent, lifetime := r.Context(), streams
if creating {
parent, lifetime = context.WithoutCancel(parent), creates
}
ctx, cancel := context.WithCancel(parent)
defer cancel()
stop := context.AfterFunc(lifetime, func() {
cancel()
if !creating {
if conn, ok := parent.Value(dockerProxyConnKey{}).(net.Conn); ok {
_ = conn.Close()
}
}
})
defer stop()
r = r.WithContext(ctx)
if creating {
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
mounts, _ := proxy.mounts.Load().(map[string]string)
if err := rewriteCreate(r, job, mounts); err != nil {
status := http.StatusBadRequest
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, err.Error(), status)
return
}
} else if r.Method == http.MethodPost && rawStreamPath.MatchString(r.URL.Path) {
tunnel(w, r, dial, forward)
return
}
forward.ServeHTTP(w, r)
})}
served := make(chan struct{})
go func() {
defer close(served)
_ = server.Serve(listener)
}()
proxy.close = func(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
admission.Lock()
listenerErr := listener.Close()
cancelStreams()
admission.Unlock()
<-served
shutdownErr := server.Shutdown(ctx)
cancelCreates()
serverErr := server.Close()
handlers.Wait()
transport.CloseIdleConnections()
return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
}
return proxy, nil
}
func rewriteCreate(r *http.Request, job string, mounts map[string]string) error {
body, err := io.ReadAll(r.Body)
if err != nil {
return err
}
if len(bytes.TrimSpace(body)) == 0 {
body = []byte("{}")
}
var fields map[string]json.RawMessage
var config struct{ Labels map[string]string }
if err := json.Unmarshal(body, &fields); err != nil {
return fmt.Errorf("invalid create request: %w", err)
}
if err := json.Unmarshal(body, &config); err != nil {
return fmt.Errorf("invalid create labels: %w", err)
}
if fields == nil {
fields = make(map[string]json.RawMessage)
}
if len(mounts) > 0 && !hasAmbiguousFields(body) {
translateBinds(fields, createPath.FindStringSubmatch(r.URL.Path)[2], mounts)
}
maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
return strings.EqualFold(name, "Labels")
})
if config.Labels == nil {
config.Labels = make(map[string]string)
}
config.Labels[jobLabel] = job
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
return err
}
if body, err = json.Marshal(fields); err != nil {
return err
}
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
r.TransferEncoding = nil
return nil
}
func translateBinds(fields map[string]json.RawMessage, kind string, mounts map[string]string) {
cleanedSource := func(source string) string {
cleaned := path.Clean(source)
if target := jobMount(cleaned, mounts); mounts[target] != "" {
return mounts[target] + cleaned[len(target):]
}
return source
}
spelledSource := func(source string) string { // dockerd checks these as spelled
target := jobMount(path.Clean(source), mounts)
if rest, spelled := strings.CutPrefix(source, target); mounts[target] != "" && spelled && (rest == "" || rest[0] == '/') && filepath.IsLocal("."+rest) {
return mounts[target] + rest
}
return cleanedSource(source)
}
switch kind {
case "volumes":
var driver string
var options map[string]any
decodeField(fields, "Driver", &driver)
if key := decodeField(fields, "DriverOpts", &options); options != nil {
translateDevice(driver, options, spelledSource)
if encoded, err := json.Marshal(options); err == nil {
fields[key] = encoded
}
}
case "containers":
var hostConfig map[string]any
key := decodeField(fields, "HostConfig", &hostConfig)
binds, _ := field(hostConfig, "Binds").([]any)
for i, bind := range binds {
bind, _ := bind.(string)
source, target, _ := strings.Cut(bind, ":")
if translated := cleanedSource(source); strings.HasPrefix(target, "/") && !strings.Contains(translated, ":") {
binds[i] = translated + ":" + target
}
}
specs, _ := field(hostConfig, "Mounts").([]any)
for _, spec := range specs {
spec, _ := spec.(map[string]any)
switch field(spec, "Type") {
case "bind":
if source, ok := field(spec, "Source").(string); ok {
spec["Source"] = spelledSource(source)
}
case "volume":
volumeOptions, _ := field(spec, "VolumeOptions").(map[string]any)
driverConfig, _ := field(volumeOptions, "DriverConfig").(map[string]any)
translateDevice(field(driverConfig, "Name"), field(driverConfig, "Options"), spelledSource)
}
}
if encoded, err := json.Marshal(hostConfig); err == nil && hostConfig != nil {
fields[key] = encoded
}
}
}
func decodeField(fields map[string]json.RawMessage, name string, value any) string {
for key, raw := range fields {
if strings.EqualFold(key, name) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
_ = decoder.Decode(value) // wrong types stay unset for dockerd to reject
return key
}
}
return ""
}
func translateDevice(driver, options any, translate func(string) string) {
optionMap, _ := options.(map[string]any)
device, ok := optionMap["device"].(string)
flags, _ := optionMap["o"].(string)
tokens := strings.Split(flags, ",")
local := driver == nil || driver == "" || driver == "local"
if ok && local && (slices.Contains(tokens, "bind") || slices.Contains(tokens, "rbind")) && !slices.Contains(tokens, "remount") {
optionMap["device"] = translate(device)
}
}
// field also renames the matched key to name.
func field(object map[string]any, name string) any {
for key, value := range object {
if strings.EqualFold(key, name) {
delete(object, key)
object[name] = value
return value
}
}
return nil
}
var (
requestFields = []string{"hostconfig", "driver", "driveropts"}
asciiFolds = strings.NewReplacer("ſ", "s", "K", "k") // the non-ASCII runes strings.EqualFold matches to ASCII letters
)
// dockerd settles repeated names by order, which re-encoding loses.
func hasAmbiguousFields(body []byte) bool {
type frame struct {
names map[string]bool
key string
expectKey bool
nested bool
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
stack := []*frame{{}}
for {
token, err := decoder.Token()
if err != nil {
return !errors.Is(err, io.EOF)
}
top := stack[len(stack)-1]
if name, ok := token.(string); ok && top.expectKey {
top.key, top.expectKey = strings.ToLower(asciiFolds.Replace(name)), false
if top.names[top.key] && (top.nested || slices.Contains(requestFields, top.key)) {
return true
}
top.names[top.key] = true
continue
}
top.expectKey = top.names != nil
nested := top.nested || len(stack) == 2 && slices.Contains(requestFields, top.key)
switch token {
case json.Delim('{'):
stack = append(stack, &frame{names: map[string]bool{}, expectKey: true, nested: nested})
case json.Delim('['):
stack = append(stack, &frame{nested: nested})
case json.Delim('}'), json.Delim(']'):
stack = stack[:len(stack)-1]
}
}
}
// jobMount returns "" also for a path already naming a daemon source.
func jobMount(source string, mounts map[string]string) string {
target := ""
for destination, daemonSource := range mounts {
if daemonSource != "" && (source == daemonSource || strings.HasPrefix(source, daemonSource+"/")) {
return ""
}
if len(destination) > len(target) && (source == destination || strings.HasPrefix(source, destination+"/")) {
target = destination
}
}
return target
}
type dockerProxyConnKey struct{}
type dockerProxyResponse struct {
response *http.Response
}
func (r dockerProxyResponse) RoundTrip(_ *http.Request) (*http.Response, error) {
return r.response, nil
}
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error), forward *httputil.ReverseProxy) {
upstream, err := dial(r.Context(), "", "")
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer upstream.Close()
stop := context.AfterFunc(r.Context(), func() { _ = upstream.Close() })
defer stop()
if err := r.Write(upstream); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
reader := bufio.NewReader(upstream)
var response *http.Response
for {
response, err = http.ReadResponse(reader, r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if response.StatusCode >= 200 || response.StatusCode == http.StatusSwitchingProtocols {
break
}
maps.Copy(w.Header(), response.Header)
w.WriteHeader(response.StatusCode)
clear(w.Header())
_ = response.Body.Close()
}
defer func() {
_ = upstream.Close()
_ = response.Body.Close()
}()
mediaType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type"))
if response.StatusCode != http.StatusSwitchingProtocols && (response.StatusCode != http.StatusOK || mediaType != "application/vnd.docker.raw-stream") {
ordinary := *forward
ordinary.Transport = dockerProxyResponse{response: response}
ordinary.ServeHTTP(w, r)
return
}
downstream, buffered, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer downstream.Close()
if _, err := fmt.Fprintf(buffered, "%s %s\r\n", response.Proto, response.Status); err != nil {
return
}
if err := response.Header.Write(buffered); err != nil {
return
}
if _, err := buffered.WriteString("\r\n"); err != nil {
return
}
if err := buffered.Flush(); err != nil {
return
}
done := make(chan struct{})
go func() {
defer close(done)
if _, err := io.Copy(upstream, io.MultiReader(io.LimitReader(buffered, int64(buffered.Reader.Buffered())), downstream)); err != nil { // Bypass net/http after the prefix so stdin EOF preserves output.
_ = upstream.Close()
} else if writer, ok := upstream.(interface{ CloseWrite() error }); ok {
_ = writer.CloseWrite()
} else {
_ = upstream.Close()
}
}()
_, _ = io.Copy(downstream, reader)
_ = downstream.Close()
_ = upstream.Close()
<-done
}
func RemoveDockerJobResources(ctx context.Context, job string) error {
cli, err := GetDockerClient(ctx)
if err != nil {
return err
}
defer cli.Close()
return removeLabelled(ctx, cli, job)
}
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
logger := common.Logger(ctx)
filters := make(client.Filters).Add("label", jobLabel+"="+job)
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
errs := []error{err}
for _, c := range containers.Items {
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
}
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
errs = append(errs, err)
for _, n := range networks.Items {
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); n.Scope == "swarm" && cerrdefs.IsInvalidArgument(err) { // swarm refuses while a service or its tasks use it
logger.Infof("keeping network %s, a swarm service still uses it", n.Name)
} else if err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
}
}
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
errs = append(errs, err)
for _, v := range volumes.Items {
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
}
}
return errors.Join(errs...)
}

View File

@@ -0,0 +1,543 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"archive/tar"
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/volume"
mobyclient "github.com/moby/moby/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func (m *mockDockerClient) VolumeList(ctx context.Context, opts mobyclient.VolumeListOptions) (mobyclient.VolumeListResult, error) {
args := m.Called(ctx, opts)
return args.Get(0).(mobyclient.VolumeListResult), args.Error(1)
}
func (m *mockDockerClient) VolumeRemove(ctx context.Context, id string, opts mobyclient.VolumeRemoveOptions) (mobyclient.VolumeRemoveResult, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.VolumeRemoveResult), args.Error(1)
}
// unix socket paths are limited to about a hundred bytes, which the default macOS TMPDIR exceeds
func shortTempDir(t testing.TB) string {
t.Helper()
t.Setenv("TMPDIR", "/tmp")
return t.TempDir()
}
func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
t.Helper()
host := cli.DaemonHost()
if !strings.HasPrefix(host, "unix://") {
t.Skipf("skipping: daemon at %s is not a unix socket", host)
}
return strings.TrimPrefix(host, "unix://")
}
func TestDockerProxy(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix socket ownership is unavailable on Windows")
}
bodies := make(chan []byte, 8)
createStarted, releaseCreate := make(chan struct{}), make(chan struct{})
streamsDone := make(chan struct{}, 2)
daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
listener, err := net.Listen("unix", daemonSocket)
require.NoError(t, err)
daemon := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/create"):
body, _ := io.ReadAll(r.Body)
if r.URL.RawQuery == "wait" {
close(createStarted)
<-releaseCreate
}
bodies <- body
w.WriteHeader(http.StatusCreated)
case r.URL.Path == "/exec/detached/start" || r.URL.Path == "/exec/error/start":
if r.URL.Path == "/exec/error/start" {
w.Header().Set("Content-Type", "application/vnd.docker.raw-stream")
w.WriteHeader(http.StatusBadRequest)
}
_, _ = w.Write([]byte("ordinary"))
case rawStreamPath.MatchString(r.URL.Path) || r.URL.Path == "/session":
_, _ = io.Copy(io.Discard, r.Body)
conn, buffered, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer conn.Close()
if r.Header.Get("Upgrade") != "" {
_, _ = conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nready\n"))
} else {
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\nready\n"))
}
input, _ := io.ReadAll(buffered)
_, _ = conn.Write(append([]byte("final:"), input...))
streamsDone <- struct{}{}
default:
w.Header().Set("Api-Version", "1.47")
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
}
})}
go func() { _ = daemon.Serve(listener) }()
t.Cleanup(func() { _ = daemon.Close() })
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
require.NoError(t, err)
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
proxy.SetMounts(map[string]string{"/workspace/o/r": "/volumes/job/_data", "/workspace/o/r/tmp": "", "/var/run/docker.sock": "/tmp/p/docker.sock", "/volumes": "/daemon/volumes"})
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket)
}}}
t.Cleanup(client.CloseIdleConnections)
dialProxy := func(t *testing.T) *net.UnixConn {
t.Helper()
conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: proxy.Socket, Net: "unix"})
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
return conn
}
startStream := func(t *testing.T, path, upgrade string) (*net.UnixConn, *bufio.Reader) {
t.Helper()
conn := dialProxy(t)
_, err := fmt.Fprintf(conn, "POST %s HTTP/1.1\r\nHost: docker\r\n%sContent-Length: 2\r\n\r\n{}input", path, upgrade)
require.NoError(t, err)
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, nil)
require.NoError(t, err)
t.Cleanup(func() {
_ = conn.Close()
_ = resp.Body.Close()
})
prefix, err := reader.ReadString('\n')
require.NoError(t, err)
require.Equal(t, "ready\n", prefix)
return conn, reader
}
t.Run("labels creates", func(t *testing.T) {
for _, testCase := range []struct{ path, body, want string }{
{
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`,
},
{
"/containers/create", `{"HostConfig":{"Binds":["/workspace/o/r:/src:ro","/workspace/o/r/../r/sub/:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2"}}`,
`{"HostConfig":{"Binds":["/volumes/job/_data:/src:ro","/volumes/job/_data/sub:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2","com.gitea.runner.job":"job-1"}}`,
},
{
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/var/run/docker.sock","Target":"/var/run/docker.sock"},{"type":"bind","source":"/workspace/o/r/sub/","target":"/m"},{"Type":"bind","Source":"/workspace/o/r/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/workspace/o/r/../r","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]}}`,
`{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/tmp/p/docker.sock","Target":"/var/run/docker.sock"},{"Type":"bind","Source":"/volumes/job/_data/sub/","target":"/m"},{"Type":"bind","Source":"/volumes/job/_data/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/volumes/job/_data","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/workspace/o/r/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]}}`,
`{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/volumes/job/_data/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/containers/create", `{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null}`,
`{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null,"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/networks/create", `{"Name":"n"}`,
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/volumes/create", `{"Name":"v","labels":null,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
`{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"},"DriverOpts":{"type":"none","o":"rbind,ro","device":"/volumes/job/_data/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
},
{
"/volumes/create", `{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"}}`,
`{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/volumes/create", `{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"}}`,
`{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/volumes/create", "",
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/volumes/create", "null",
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/volumes/create", `{"Labels":{"discard":"1"},"labels":null,"LABELS":{"own":"1"},"LABELS":{"extra":"2"}}`,
`{"Labels":{"own":"1","extra":"2","com.gitea.runner.job":"job-1"}}`,
},
} {
resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
assert.JSONEq(t, testCase.want, string(<-bodies), testCase.body)
}
})
t.Run("passes other requests through", func(t *testing.T) {
resp, err := client.Get("http://docker/v1.47/_ping")
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "1.47", resp.Header.Get("Api-Version"))
assert.Equal(t, "OK GET /v1.47/_ping", string(body))
})
t.Run("tunnels raw streams through stdin EOF", func(t *testing.T) {
for _, stream := range []struct{ path, upgrade string }{
{path: "/v1.47/exec/abc/start"},
{path: "/containers/abc/attach", upgrade: "Connection: Upgrade\r\nUpgrade: tcp\r\n"},
} {
conn, reader := startStream(t, stream.path, stream.upgrade)
require.NoError(t, conn.CloseWrite())
output, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "final:input", string(output))
<-streamsDone
}
})
t.Run("detached and error responses retain keepalive label injection", func(t *testing.T) {
conn := dialProxy(t)
reader := bufio.NewReader(conn)
for endpoint, status := range map[string]int{"detached": http.StatusOK, "error": http.StatusBadRequest} {
_, err := fmt.Fprintf(conn, "POST /exec/%s/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}", endpoint)
require.NoError(t, err)
resp, err := http.ReadResponse(reader, nil)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, status, resp.StatusCode)
assert.Equal(t, "ordinary", string(body))
_, err = io.WriteString(conn, "POST /volumes/create HTTP/1.1\r\nHost: docker\r\nContent-Length: 0\r\n\r\n")
require.NoError(t, err)
resp, err = http.ReadResponse(reader, nil)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode)
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
}
})
t.Run("close joins streams and admitted create", func(t *testing.T) {
t.Cleanup(func() { close(releaseCreate) })
_, raw := startStream(t, "/exec/live/start", "")
_, upgraded := startStream(t, "/session", "Connection: Upgrade\r\nUpgrade: tcp\r\n")
creator := dialProxy(t)
_, err := io.WriteString(creator, "POST /volumes/create?wait HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}")
require.NoError(t, err)
<-createStarted
require.NoError(t, creator.CloseWrite())
closed := make(chan error, 1)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
go func() { closed <- proxy.Close(ctx) }()
for _, reader := range []*bufio.Reader{raw, upgraded} {
_, err := reader.ReadByte()
require.ErrorIs(t, err, io.EOF)
<-streamsDone
}
select {
case err := <-closed:
t.Fatalf("Close returned before admitted create settled: %v", err)
default:
}
releaseCreate <- struct{}{}
resp, err := http.ReadResponse(bufio.NewReader(creator), nil)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode)
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
require.NoError(t, <-closed)
})
}
func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
containerFailure := errors.New("container removal failed")
listFailure := errors.New("listing failed")
volumeFailure := errors.New("volume removal failed")
ctx := context.Background()
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
cli := &mockDockerClient{}
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once()
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once()
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "stack_default", Scope: "swarm"}}}, listFailure).Once()
cli.On("NetworkRemove", ctx, "n1", mobyclient.NetworkRemoveOptions{}).Return(mobyclient.NetworkRemoveResult{}, cerrdefs.ErrInvalidArgument).Once()
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
err := removeLabelled(ctx, cli, "job-1")
require.ErrorIs(t, err, containerFailure)
require.ErrorIs(t, err, listFailure)
require.ErrorIs(t, err, volumeFailure)
require.NotErrorIs(t, err, cerrdefs.ErrInvalidArgument)
require.ErrorContains(t, err, "failed to remove container c1")
require.ErrorContains(t, err, "failed to remove volume app_data")
cli.AssertExpectations(t)
}
func TestDockerProxyWithDaemon(t *testing.T) {
requireDocker(t)
ctx := context.Background()
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
direct, err := GetDockerClient(ctx)
require.NoError(t, err)
defer direct.Close()
dir := shortTempDir(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)
require.NoError(t, err)
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
require.NoError(t, err)
defer viaProxy.Close()
created, err := viaProxy.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
})
require.NoError(t, err)
t.Cleanup(func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) })
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
require.NoError(t, err)
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
require.NoError(t, err)
t.Cleanup(func() { _, _ = direct.NetworkRemove(ctx, net.ID, mobyclient.NetworkRemoveOptions{}) })
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
require.NoError(t, err)
t.Cleanup(func() { _, _ = direct.VolumeRemove(ctx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
require.NoError(t, err)
assert.Equal(t, map[string]string{"own": "1", jobLabel: job}, inspected.Container.Config.Labels)
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
require.NoError(t, err)
assert.Equal(t, job, vol.Volume.Labels[jobLabel])
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
require.NoError(t, err)
attached, err := viaProxy.ExecAttach(ctx, exec.ID, mobyclient.ExecAttachOptions{TTY: true})
require.NoError(t, err)
_, err = attached.Conn.Write([]byte("hello\n"))
require.NoError(t, err)
echoed, err := attached.Reader.ReadString('\n')
require.NoError(t, err)
assert.Equal(t, "hello\r\n", echoed)
attached.Close()
require.NoError(t, proxy.Close(ctx))
require.NoError(t, RemoveDockerJobResources(ctx, job))
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err))
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err))
_, err = direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err))
}
func BenchmarkDockerProxy(b *testing.B) {
ctx := context.Background()
direct, err := GetDockerClient(ctx)
require.NoError(b, err)
defer direct.Close()
if _, err := direct.Ping(ctx, mobyclient.PingOptions{}); err != nil {
b.Skipf("docker daemon unreachable: %v", err)
}
proxy, err := StartDockerProxy(daemonSocketPath(b, direct), shortTempDir(b), "bench")
require.NoError(b, err)
defer func() { _ = proxy.Close(ctx) }()
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
require.NoError(b, err)
defer viaProxy.Close()
require.NoError(b, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
created, err := direct.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "600"}}})
require.NoError(b, err)
defer func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) }()
_, err = direct.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
require.NoError(b, err)
var archive bytes.Buffer
writer := tar.NewWriter(&archive)
payload := make([]byte, 64<<20)
require.NoError(b, writer.WriteHeader(&tar.Header{Name: "blob", Mode: 0o600, Size: int64(len(payload))}))
_, _ = writer.Write(payload)
require.NoError(b, writer.Close())
for name, cli := range map[string]mobyclient.APIClient{"direct": direct, "proxy": viaProxy} {
b.Run("ping/"+name, func(b *testing.B) {
for b.Loop() {
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
b.Fatal(err)
}
}
})
b.Run("copy64MiB/"+name, func(b *testing.B) {
b.SetBytes(int64(archive.Len()))
for b.Loop() {
_, err := cli.CopyToContainer(ctx, created.ID, mobyclient.CopyToContainerOptions{DestinationPath: "/tmp", Content: bytes.NewReader(archive.Bytes())})
if err != nil {
b.Fatal(err)
}
}
})
}
}
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) {
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "probe-image"}}}, nil
}
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
return c.create(opts)
}
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
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
daemonDir string
removeErr error
seen bool
removed bool
}{
{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()
removed := false
cli := &probeClient{
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
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(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)
assert.Equal(t, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, opts)
require.NoError(t, ctx.Err())
_, bounded := ctx.Deadline()
assert.True(t, bounded)
return mobyclient.ContainerRemoveResult{}, testCase.removeErr
},
}
seen, err := daemonSeesDir(ctx, cli, dir, testCase.daemonDir)
require.ErrorIs(t, err, testCase.removeErr)
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)
}

View File

@@ -0,0 +1,48 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
package container
import (
"encoding/binary"
"errors"
"io/fs"
"math"
"os"
"path/filepath"
"syscall"
"golang.org/x/sys/unix"
)
func copyDockerSocketPermissions(daemonSocket, socket string, info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("docker socket ownership is unavailable")
}
groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid))
if groupErr != nil && (!errors.Is(groupErr, fs.ErrPermission) || int(stat.Uid) != os.Geteuid()) {
return groupErr
}
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
return err
}
mode := uint16(info.Mode().Perm())
if err := os.Chmod(socket, fs.FileMode(mode)); err != nil || groupErr == nil {
return err
}
if size, err := unix.Getxattr(daemonSocket, "system.posix_acl_access", nil); err == nil && size > 0 {
return groupErr
}
owner, group, other := mode>>6, mode>>3&7, mode&7
acl := binary.LittleEndian.AppendUint32(nil, 2)
for _, entry := range []struct {
tag, perm uint16
id uint32
}{{1, owner, math.MaxUint32}, {4, other, math.MaxUint32}, {8, group, stat.Gid}, {16, group | other, math.MaxUint32}, {32, other, math.MaxUint32}} {
acl = binary.LittleEndian.AppendUint32(binary.LittleEndian.AppendUint16(binary.LittleEndian.AppendUint16(acl, entry.tag), entry.perm), entry.id)
}
return unix.Setxattr(socket, "system.posix_acl_access", acl, 0) // names the group a rootless runner cannot chown to, its own group keeps what others had
}

View File

@@ -0,0 +1,15 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !WITHOUT_DOCKER
package container
import (
"errors"
"os"
)
func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error {
return errors.New("docker socket ownership cannot be preserved on Windows")
}

View File

@@ -7,8 +7,8 @@
package container
import (
"archive/tar"
"bytes"
"cmp"
"context"
"errors"
"fmt"
@@ -16,6 +16,7 @@ import (
"os"
"path"
"path/filepath"
"reflect"
"regexp"
"runtime"
"slices"
@@ -24,16 +25,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"
@@ -58,7 +55,7 @@ func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
cr := new(containerReference)
cr.input = input
// Resolved up front because the image pull runs before the container is created.
cf := createFlagsFromOptions(input.Options)
cf := createFlagsFromOptions(input.allOptions())
if cf.platform != "" {
cr.input.Platform = cf.platform
}
@@ -66,37 +63,14 @@ func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
return cr
}
func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
return common.
NewDebugExecutor("docker network connect %s %s", name, cr.input.Name).
Then(
common.NewPipelineExecutor(
cr.connect(),
cr.connectToNetwork(name, cr.input.NetworkAliases),
).IfNot(common.Dryrun),
)
}
func (cr *containerReference) connectToNetwork(name string, aliases []string) common.Executor {
return func(ctx context.Context) error {
_, err := cr.cli.NetworkConnect(ctx, name, client.NetworkConnectOptions{
Container: cr.input.Name,
EndpointConfig: &network.EndpointSettings{
Aliases: aliases,
},
})
return err
}
}
// supportsContainerImagePlatform returns true if the underlying Docker server
// API version is 1.41 and beyond
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
// supportsContainerImagePlatform reports whether the Docker server API version
// is 1.41 and beyond
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) (bool, error) {
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
if err != nil {
common.Logger(ctx).Panicf("Failed to get Docker API Version: %s", err)
return false, fmt.Errorf("get docker API version: %w", err)
}
return versions.GreaterThanOrEqualTo(ver.APIVersion, "1.41")
return versions.GreaterThanOrEqualTo(ver.APIVersion, "1.41"), nil
}
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
@@ -161,12 +135,12 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
).IfNot(common.Dryrun)
}
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
return common.NewPipelineExecutor(
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
cr.connect(),
cr.find(),
cr.copyDir(destPath, srcPath, useGitIgnore),
cr.copyDir(destPath, srcPath, useGitIgnore, skipGitDir),
func(ctx context.Context) error {
// If this fails, then folders have wrong permissions on non root container
if cr.UID != 0 || cr.GID != 0 {
@@ -198,6 +172,134 @@ func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath s
return result.Content, nil
}
// Inspect resolves the container by name when its id is not known yet. One the daemon no
// longer knows is reported as ErrContainerNotFound.
func (cr *containerReference) Inspect(ctx context.Context) (*Info, error) {
if common.Dryrun(ctx) {
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
}
if err := cr.connect()(ctx); err != nil {
return nil, err
}
if cr.id == "" { // a known id is trusted, find() would spend a call validating it
if err := cr.find()(ctx); err != nil {
return nil, err
}
}
if cr.id == "" {
return nil, cr.missingContainerError("inspect it")
}
result, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
if cerrdefs.IsNotFound(err) {
return nil, cr.missingContainerError("inspect it")
} else if err != nil {
return nil, err
}
return containerInfoFromInspect(result.Container), nil
}
// DumpLogs copies the container's log so far to its output writers.
func (cr *containerReference) DumpLogs(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
if err := cr.connect()(ctx); err != nil {
return err
}
if cr.id == "" {
return cr.missingContainerError("read its logs")
}
logs, err := cr.cli.ContainerLogs(ctx, cr.id, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil {
return err
}
defer logs.Close()
return cr.copyOutput(logs)
}
// copyOutput writes a container stream to the writers the container was created with,
// demultiplexing it unless the container has a TTY, which sends a single raw stream.
func (cr *containerReference) copyOutput(stream io.Reader) error {
outWriter := cr.input.Stdout
if outWriter == nil {
outWriter = os.Stdout
}
errWriter := cr.input.Stderr
if errWriter == nil {
errWriter = os.Stderr
}
var err error
if !cr.input.AllocatePTY || os.Getenv("NORAW") != "" {
_, err = stdcopy.StdCopy(outWriter, errWriter, stream)
} else {
_, err = io.Copy(outWriter, stream)
}
// Flush any buffered, not-yet-newline-terminated trailing line so the final line of
// the output is not lost when it is not newline-terminated.
common.FlushWriter(outWriter)
common.FlushWriter(errWriter)
return err
}
func containerInfoFromInspect(inspect container.InspectResponse) *Info {
info := &Info{
ID: inspect.ID,
Health: HealthNone,
Ports: map[string]string{}, // an empty map, never null, in the expression context
Mounts: make(map[string]string, len(inspect.Mounts)),
}
for _, mountPoint := range inspect.Mounts {
info.Mounts[mountPoint.Destination] = mountPoint.Source
}
if hostConfig := inspect.HostConfig; hostConfig != nil { // Mounts omits --tmpfs targets and subpaths
for target := range hostConfig.Tmpfs {
info.Mounts[path.Clean(target)] = ""
}
for _, spec := range hostConfig.Mounts {
subpath := ""
if spec.VolumeOptions != nil {
subpath = spec.VolumeOptions.Subpath
} else if spec.ImageOptions != nil {
subpath = spec.ImageOptions.Subpath
}
if target := path.Clean(spec.Target); subpath != "" && info.Mounts[target] != "" {
info.Mounts[target] = path.Join(info.Mounts[target], subpath)
}
}
}
for _, target := range []string{"/etc/hosts", "/etc/hostname", "/etc/resolv.conf"} { // specific to the job's network namespace
if _, mounted := info.Mounts[target]; !mounted {
info.Mounts[target] = ""
}
}
if state := inspect.State; state != nil {
info.State = string(state.Status)
info.ExitCode = state.ExitCode
if health := state.Health; health != nil {
info.Health = string(health.Status)
if len(health.Log) > 0 {
info.HealthOutput = strings.TrimSpace(health.Log[len(health.Log)-1].Output)
}
}
}
if settings := inspect.NetworkSettings; settings != nil {
for port, bindings := range settings.Ports {
for _, binding := range bindings { // the last binding wins, a port maps to one host port
if binding.HostPort != "" {
info.Ports[port.Port()] = binding.HostPort
}
}
}
}
return info
}
func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
return parseEnvFile(cr, srcPath, env).IfNot(common.Dryrun)
}
@@ -343,10 +445,10 @@ func (cr *containerReference) Close() common.Executor {
}
}
// missingContainerError is the shared "container X does not exist" error
// used by ops that need a live cr.id.
// missingContainerError is the shared "container X does not exist" error used by ops that
// need a live cr.id, wrapping ErrContainerNotFound so a caller can tell it from a failing daemon.
func (cr *containerReference) missingContainerError(format string, args ...any) error {
return fmt.Errorf("container %q does not exist; cannot "+format, append([]any{cr.input.Name}, args...)...)
return fmt.Errorf("container %q %w; cannot "+format, append([]any{cr.input.Name, ErrContainerNotFound}, args...)...)
}
func (cr *containerReference) find() common.Executor {
@@ -404,14 +506,11 @@ func (cr *containerReference) remove() common.Executor {
RemoveVolumes: true,
Force: true,
})
switch {
case cerrdefs.IsConflict(err):
// the daemon's own AutoRemove teardown is running, and it releases the volume
// references and the network endpoint only once it finishes
cr.waitForRemoval(ctx, idOrName)
case err != nil && !cerrdefs.IsNotFound(err):
logger.Error(fmt.Errorf("failed to remove container %s: %w", idOrName, err))
return nil // keep the id, the container is still there for a later Remove()
if cerrdefs.IsConflict(err) {
err = cr.waitForRemoval(ctx, idOrName)
}
if err != nil && !cerrdefs.IsNotFound(err) {
return fmt.Errorf("failed to remove container %s: %w", idOrName, err)
}
logger.Debugf("Removed container: %v", idOrName)
@@ -420,7 +519,7 @@ func (cr *containerReference) remove() common.Executor {
}
}
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) error {
// per container, against the one minute the post-job executor allows for the whole
// cleanup, so a job with several services can spend most of that budget here
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
@@ -430,44 +529,49 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
Condition: container.WaitConditionRemoved,
})
select {
case <-waitResult.Result:
case <-waitResult.Error:
case <-ctx.Done():
// the client delivers the result over an unbuffered channel, so leave a receiver
// behind or its goroutine parks on the send for the lifetime of the process
go func() {
select {
case <-waitResult.Result:
case <-waitResult.Error:
}
}()
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
case result := <-waitResult.Result:
if result.Error != nil {
return errors.New(result.Error.Message)
}
return nil
case err := <-waitResult.Error:
return err
}
}
// allOptions puts the runner's options first, so a flag both sources set ends up the workflow's.
func (input *NewContainerInput) allOptions() string {
return strings.TrimSpace(input.RunnerOptions + " " + input.WorkflowOptions)
}
func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig, error) {
logger := common.Logger(ctx)
input := cr.input
options := cr.input.allOptions()
if input.Options == "" {
if options == "" {
return config, hostConfig, nil
}
// For Gitea, checked here because the parse below is what would read those files
if err := rejectHostReadingOptions(cr.input.WorkflowOptions); err != nil {
return nil, nil, err
}
// parse configuration from CLI container.options
flags, copts, cf, err := parseContainerOptions(input.Options)
flags, copts, cf, err := parseContainerOptions(options)
if err != nil {
return nil, nil, err
}
if err := cf.validate(); err != nil {
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
}
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
// In the old fork version, the code is
// if len(copts.netMode.Value()) == 0 {
// if err = copts.netMode.Set("host"); err != nil {
// return nil, nil, fmt.Errorf("Cannot parse networkmode=host. This is an internal error and should not happen: '%w'", err)
// return nil, nil, fmt.Errorf("cannot parse networkmode=host. This is an internal error and should not happen: '%w'", err)
// }
// }
// And it has been commented with:
@@ -479,55 +583,65 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
if len(copts.netMode.Value()) == 0 {
if err = copts.netMode.Set(cr.input.NetworkMode); err != nil {
return nil, nil, fmt.Errorf("Cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err)
return nil, nil, fmt.Errorf("cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err)
}
}
// 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
}
containerConfig, err := parse(flags, copts, runtime.GOOS)
if err != nil {
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
}
// workflow aliases join the runner's own, deduped so a re-create cannot grow the input
for _, endpoint := range containerConfig.NetworkingConfig.EndpointsConfig {
for _, alias := range endpoint.Aliases {
if !slices.Contains(cr.input.NetworkAliases, alias) {
cr.input.NetworkAliases = append(cr.input.NetworkAliases, alias)
}
}
}
// For Gitea
// When privileged mode is disabled, container.options is workflow-controlled
// untrusted input. Strip the HostConfig fields that would let a workflow break
// out of the container (host namespaces, capability expansion, security profile
// overrides, device and runtime access). Otherwise these survive into the final
// HostConfig even though --privileged is forced off.
// For Gitea, forcing --privileged off is not enough, other options reach the host too
if !hostConfig.Privileged {
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig)
trusted, err := parseOptionsHostConfig(cr.input.RunnerOptions)
if err != nil {
return nil, nil, err
}
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig, trusted)
}
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
if err != nil {
return nil, nil, fmt.Errorf("Cannot merge container.Config options: '%s': '%w'", input.Options, err)
return nil, nil, fmt.Errorf("cannot merge container.Config options: '%s': '%w'", options, err)
}
logger.Debugf("Merged container.Config ==> %+v", 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
networkMode := hostConfig.NetworkMode
err = mergo.Merge(hostConfig, containerConfig.HostConfig, mergo.WithOverride)
if err != nil {
return nil, nil, fmt.Errorf("Cannot merge container.HostConfig options: '%s': '%w'", input.Options, err)
return nil, nil, fmt.Errorf("cannot merge container.HostConfig options: '%s': '%w'", options, err)
}
hostConfig.Binds = binds
hostConfig.Mounts = mounts
if cf.name != "" {
logger.Warn("--name in the options will be ignored.")
}
if len(copts.netMode.Value()) > 0 {
// the runner's own network mode was put into copts above, so ask the flags instead
if flags.Changed("network") || flags.Changed("net") {
logger.Warn("--network and --net in the options will be ignored.")
}
hostConfig.NetworkMode = networkMode
@@ -580,11 +694,17 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
}
var platSpecs *specs.Platform
if cr.input.Platform != "" && supportsContainerImagePlatform(ctx, cr.cli) {
platSpecs, err = parsePlatform(cr.input.Platform)
if cr.input.Platform != "" {
// Dropping the platform silently would build for the host arch.
supported, err := supportsContainerImagePlatform(ctx, cr.cli)
if err != nil {
return err
}
if supported {
if platSpecs, err = parsePlatform(cr.input.Platform); err != nil {
return err
}
}
}
hostConfig := &container.HostConfig{
@@ -737,7 +857,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
}
defer resp.Close()
err = cr.waitForCommand(ctx, isTerminal, resp.HijackedResponse, idResp, user, workdir)
err = cr.waitForCommand(ctx, resp.HijackedResponse, idResp, user, workdir)
if err != nil {
return err
}
@@ -795,7 +915,7 @@ func (cr *containerReference) tryReadGID() common.Executor {
return cr.tryReadID("-g", func(id int) { cr.GID = id })
}
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
func (cr *containerReference) waitForCommand(ctx context.Context, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
logger := common.Logger(ctx)
// Buffered so the copy goroutine never blocks on send if the grace-period
@@ -803,28 +923,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
cmdResponse := make(chan error, 1)
go func() {
var outWriter io.Writer
outWriter = cr.input.Stdout
if outWriter == nil {
outWriter = os.Stdout
}
errWriter := cr.input.Stderr
if errWriter == nil {
errWriter = os.Stderr
}
var err error
if !isTerminal || os.Getenv("NORAW") != "" {
_, err = stdcopy.StdCopy(outWriter, errWriter, resp.Reader)
} else {
_, err = io.Copy(outWriter, resp.Reader)
}
// Flush any buffered, not-yet-newline-terminated trailing line so the
// final line of a command's output is not lost (e.g. an error message
// printed without a trailing newline before the process exits).
common.FlushWriter(outWriter)
common.FlushWriter(errWriter)
cmdResponse <- err
cmdResponse <- cr.copyOutput(resp.Reader)
}()
select {
@@ -857,87 +956,12 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
}
}
// mkdirInContainer creates containerPath and returns it with the symlinked components
// replaced by the targets the daemon reports for them. Docker 29.7 rejects tar entries
// traversing a symlink to an absolute target, like the "/var/run" of most images, with
// "path escapes from parent", and not every daemon creates the implied parents of a
// directory entry, so one entry per missing component is extracted at the deepest
// existing ancestor.
// WORKAROUND: https://github.com/moby/moby/issues/53258
func (cr *containerReference) mkdirInContainer(ctx context.Context, containerPath string) (string, error) {
parts := strings.Split(strings.Trim(path.Clean(containerPath), "/"), "/")
existing := "/"
for i, part := range parts {
if part == "" {
return existing, nil
}
stat, err := cr.cli.ContainerStatPath(ctx, cr.id, client.ContainerStatPathOptions{Path: path.Join(existing, part)})
if err != nil {
// nothing below exists either, so create the remaining components
return path.Join(existing, path.Join(parts[i:]...)), cr.mkdirEntries(ctx, existing, parts[i:])
}
existing = path.Join(existing, part)
if target := stat.Stat.LinkTarget; target != "" {
if !path.IsAbs(target) {
target = path.Join(path.Dir(existing), target)
}
existing = target
}
}
return existing, nil
}
func (cr *containerReference) mkdirEntries(ctx context.Context, destPath string, missing []string) error {
buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
for i := range missing {
_ = tw.WriteHeader(&tar.Header{
Name: path.Join(missing[:i+1]...),
Mode: 0o777,
Typeflag: tar.TypeDir,
})
}
tw.Close()
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: destPath,
Content: buf,
})
return err
}
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
if cr.id == "" {
return cr.missingContainerError("copy to %s", destPath)
}
destPath, err := cr.mkdirInContainer(ctx, destPath)
if err != nil {
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
}
// Copy Content
_, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: destPath,
Content: tarStream,
})
if err != nil {
return fmt.Errorf("failed to copy content to container: %w", err)
}
// If this fails, then folders have wrong permissions on non root container
if cr.UID != 0 || cr.GID != 0 {
_ = cr.Exec([]string{"chown", "-R", fmt.Sprintf("%d:%d", cr.UID, cr.GID), destPath}, nil, "0", "")(ctx)
}
return nil
}
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
return func(ctx context.Context) error {
if cr.id == "" {
return cr.missingContainerError("copy directory to %s", dstPath)
}
logger := common.Logger(ctx)
dstPath, err := cr.mkdirInContainer(ctx, dstPath)
if err != nil {
return fmt.Errorf("failed to mkdir to copy directory to container: %w", err)
}
tarFile, err := os.CreateTemp("", "act")
if err != nil {
return err
@@ -954,42 +978,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
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{
Fs: &filecollector.DefaultFs{},
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
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
}
@@ -1014,30 +1003,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,
@@ -1059,33 +1030,11 @@ func (cr *containerReference) attach() common.Executor {
if err != nil {
return fmt.Errorf("failed to attach to container: %w", err)
}
isTerminal := cr.input.AllocatePTY
var outWriter io.Writer
outWriter = cr.input.Stdout
if outWriter == nil {
outWriter = os.Stdout
}
errWriter := cr.input.Stderr
if errWriter == nil {
errWriter = os.Stderr
}
done := make(chan struct{})
cr.attachDone = done
go func() {
defer close(done)
var copyErr error
if !isTerminal || os.Getenv("NORAW") != "" {
_, copyErr = stdcopy.StdCopy(outWriter, errWriter, out.Reader)
} else {
_, copyErr = io.Copy(outWriter, out.Reader)
}
// Flush any buffered, not-yet-newline-terminated trailing line once
// the stream reaches EOF, so the final line of the container's
// output is not lost when it is not newline-terminated.
common.FlushWriter(outWriter)
common.FlushWriter(errWriter)
if copyErr != nil {
if copyErr := cr.copyOutput(out.Reader); copyErr != nil {
common.Logger(ctx).Error(copyErr)
}
}()
@@ -1146,83 +1095,77 @@ func (cr *containerReference) wait() common.Executor {
}
// For Gitea
// sanitizeOptionsHostConfig clears the HostConfig fields parsed from a
// workflow-controlled container.options string that could be used to escape the
// container when privileged mode is disabled. It must only be called when the
// runner has privileged mode turned off; with privileged mode enabled the
// administrator has already opted into host access.
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig *container.HostConfig) {
warn := func(option string) {
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", option)
}
// sanitizeOptionsHostConfig takes back everything a workflow could escape the container with,
// setting each field to trusted, which is what the runner's own options parse to on their own.
// Only for unprivileged mode, since privileged mode grants host access anyway.
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig, trusted *container.HostConfig) {
resetOption(logger, "--pid", &hostConfig.PidMode, trusted.PidMode)
resetOption(logger, "--ipc", &hostConfig.IpcMode, trusted.IpcMode)
resetOption(logger, "--uts", &hostConfig.UTSMode, trusted.UTSMode)
resetOption(logger, "--cgroupns", &hostConfig.CgroupnsMode, trusted.CgroupnsMode)
resetOption(logger, "--userns", &hostConfig.UsernsMode, trusted.UsernsMode) // --userns=host would undo the remapping the runner asked for
resetOption(logger, "--cap-add", &hostConfig.CapAdd, trusted.CapAdd)
resetOption(logger, "--security-opt", &hostConfig.SecurityOpt, trusted.SecurityOpt)
resetOption(logger, "--device", &hostConfig.Devices, trusted.Devices)
resetOption(logger, "--device-cgroup-rule", &hostConfig.DeviceCgroupRules, trusted.DeviceCgroupRules)
resetOption(logger, "--gpus", &hostConfig.DeviceRequests, trusted.DeviceRequests)
resetOption(logger, "--volumes-from", &hostConfig.VolumesFrom, trusted.VolumesFrom)
resetOption(logger, "--runtime", &hostConfig.Runtime, trusted.Runtime)
resetOption(logger, "--cgroup-parent", &hostConfig.CgroupParent, trusted.CgroupParent)
resetOption(logger, "--sysctl", &hostConfig.Sysctls, trusted.Sysctls)
resetOption(logger, "--isolation", &hostConfig.Isolation, trusted.Isolation) // windows: process isolation drops the hyper-v boundary
resetOption(logger, "--volume-driver", &hostConfig.VolumeDriver, trusted.VolumeDriver)
// systempaths=unconfined lands in these two rather than in SecurityOpt
resetOption(logger, "--security-opt", &hostConfig.MaskedPaths, trusted.MaskedPaths)
resetOption(logger, "--security-opt", &hostConfig.ReadonlyPaths, trusted.ReadonlyPaths)
resetOption(logger, "--log-driver", &hostConfig.LogConfig.Type, trusted.LogConfig.Type) // drivers run in the daemon, egressing from its network
resetOption(logger, "--log-opt", &hostConfig.LogConfig.Config, trusted.LogConfig.Config)
resetOption(logger, "--oom-score-adj", &hostConfig.OomScoreAdj, trusted.OomScoreAdj) // -1000 exempts it from the host OOM killer
resetOption(logger, "--oom-kill-disable", &hostConfig.OomKillDisable, trusted.OomKillDisable)
if hostConfig.PidMode != "" {
warn("--pid")
hostConfig.PidMode = ""
// a driver mounts what it likes, e.g. local with device= binds any host path, which
// valid_volumes never gets to see
hostConfig.Mounts = slices.DeleteFunc(hostConfig.Mounts, func(mt mount.Mount) bool {
if mt.VolumeOptions == nil || mt.VolumeOptions.DriverConfig == nil ||
slices.ContainsFunc(trusted.Mounts, func(t mount.Mount) bool { return reflect.DeepEqual(t, mt) }) {
return false
}
logger.Warnf("volume driver of %q in the workflow is not allowed when privileged mode is disabled and will be ignored", mt.Source)
return true
})
}
// resetOption puts a field back to the runner's own value. It compares the values rather than
// the flags, so a field that more than one option feeds cannot slip through.
func resetOption[T any](logger logrus.FieldLogger, option string, field *T, trusted T) {
if reflect.DeepEqual(*field, trusted) {
return
}
if hostConfig.IpcMode != "" {
warn("--ipc")
hostConfig.IpcMode = ""
logger.Warnf("container option %q in the workflow is not allowed when privileged mode is disabled and will be ignored", option)
*field = trusted
}
// parseOptionsHostConfig parses one options string on its own, to see what it alone asks for.
// Even "" goes through the parser, or its empty slices and maps would differ from a real parse.
func parseOptionsHostConfig(options string) (*container.HostConfig, error) {
flags, copts, _, err := parseContainerOptions(options)
if err != nil {
return nil, err
}
if hostConfig.UTSMode != "" {
warn("--uts")
hostConfig.UTSMode = ""
}
if hostConfig.CgroupnsMode != "" {
warn("--cgroupns")
hostConfig.CgroupnsMode = ""
}
// UsernsMode is set from the runner-controlled input; never let options
// override it (e.g. --userns=host disables user namespace remapping).
if hostConfig.UsernsMode != "" {
warn("--userns")
hostConfig.UsernsMode = ""
}
if len(hostConfig.CapAdd) > 0 {
warn("--cap-add")
hostConfig.CapAdd = nil
}
if len(hostConfig.SecurityOpt) > 0 {
warn("--security-opt")
hostConfig.SecurityOpt = nil
}
if len(hostConfig.Devices) > 0 {
warn("--device")
hostConfig.Devices = nil
}
if len(hostConfig.DeviceCgroupRules) > 0 {
warn("--device-cgroup-rule")
hostConfig.DeviceCgroupRules = nil
}
if len(hostConfig.DeviceRequests) > 0 {
warn("--gpus")
hostConfig.DeviceRequests = nil
}
if len(hostConfig.VolumesFrom) > 0 {
warn("--volumes-from")
hostConfig.VolumesFrom = nil
}
if hostConfig.Runtime != "" {
warn("--runtime")
hostConfig.Runtime = ""
}
if hostConfig.CgroupParent != "" {
warn("--cgroup-parent")
hostConfig.CgroupParent = ""
}
if len(hostConfig.Sysctls) > 0 {
warn("--sysctl")
hostConfig.Sysctls = nil
containerConfig, err := parse(flags, copts, runtime.GOOS)
if err != nil {
return nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
}
return containerConfig.HostConfig, nil
}
// For Gitea
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
logger := common.Logger(ctx)
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
if len(cr.input.ValidVolumes) > 0 {
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
// sanitize binds
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
for _, bind := range hostConfig.Binds {
@@ -1236,31 +1179,47 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
sanitizedBinds = append(sanitizedBinds, bind)
continue
}
if matcher.isValid(parsed.Source, mount.Type(parsed.Type)) {
if matcher.isValid(parsed.Source, mount.Type(parsed.Type), parsed.ReadOnly) {
if matcher.needsNoCopy(parsed.Source, mount.Type(parsed.Type)) && (parsed.Volume == nil || !parsed.Volume.NoCopy) {
bind += ",nocopy"
}
sanitizedBinds = append(sanitizedBinds, bind)
} else {
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
}
}
hostConfig.Binds = sanitizedBinds
// sanitize mounts
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
for _, mt := range hostConfig.Mounts {
if matcher.isValid(mt.Source, mt.Type) {
sanitizedMounts = append(sanitizedMounts, mt)
} else {
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
}
}
hostConfig.Mounts = sanitizedMounts
} else {
for _, bind := range hostConfig.Binds {
logger.Warnf("[%s] is not a valid volume, will be ignored", bind)
}
hostConfig.Binds = []string{}
hostConfig.Mounts = []mount.Mount{}
}
// sanitize mounts
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
for _, mt := range hostConfig.Mounts {
readOnly := mt.ReadOnly && (mt.BindOptions == nil || !mt.BindOptions.ReadOnlyNonRecursive) // bind-recursive=writable leaves submounts writable
if matcher.isValid(mt.Source, mt.Type, readOnly) {
if matcher.needsNoCopy(mt.Source, mt.Type) {
mt.VolumeOptions = cmp.Or(mt.VolumeOptions, &mount.VolumeOptions{})
mt.VolumeOptions.NoCopy = true
}
sanitizedMounts = append(sanitizedMounts, mt)
} else {
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
}
}
hostConfig.Mounts = sanitizedMounts
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)
@@ -1290,28 +1249,39 @@ func overlayVolumes(dst, src *container.HostConfig) {
}
type validVolumeMatcher struct {
allowAll bool
named []glob.Glob
host []glob.Glob
allowAll bool
allowAllReadOnly bool
named []validVolumeRule
host []validVolumeRule
}
type validVolumeRule struct {
pattern string
readOnly bool
}
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
logger := common.Logger(ctx)
ret := validVolumeMatcher{
named: make([]glob.Glob, 0, len(validVolumes)),
host: make([]glob.Glob, 0, len(validVolumes)),
named: make([]validVolumeRule, 0, len(validVolumes)),
host: make([]validVolumeRule, 0, len(validVolumes)),
}
for _, v := range validVolumes {
v, readOnly := strings.CutSuffix(v, ":ro")
if v == "**" {
ret.allowAll = true
if readOnly {
ret.allowAllReadOnly = true
} else {
ret.allowAll = true
}
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, validVolumeRule{pattern: v, readOnly: readOnly})
} else {
ret.named = append(ret.named, g)
logger.Errorf("invalid volume pattern %s", v)
}
continue
}
@@ -1320,40 +1290,44 @@ 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, validVolumeRule{pattern: normalized, readOnly: readOnly})
} else {
ret.host = append(ret.host, g)
logger.Errorf("invalid volume pattern %s", normalized)
}
}
return ret
}
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool {
if m.allowAll {
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type, readOnly bool) bool {
if m.allowAll || (m.allowAllReadOnly && readOnly) {
return true
}
if sourceType == mount.TypeTmpfs && source == "" { // backed by memory, exposes nothing from the host, same as --tmpfs
return true
}
rules, match := m.named, doublestar.MatchUnvalidated
if isHostVolumeSource(source, sourceType) {
normalized, err := normalizeHostVolumePath(source)
if err != nil {
return false
}
for _, g := range m.host {
if g.Match(normalized) {
return true
}
}
return false
rules, match, source = m.host, doublestar.PathMatchUnvalidated, normalized
}
for _, g := range m.named {
if g.Match(source) {
for _, rule := range rules {
if (readOnly || !rule.readOnly) && match(rule.pattern, source) {
return true
}
}
return false
}
// needsNoCopy reports a volume only `:ro` rules allow, as dockerd fills an empty volume from the image even when mounted read-only.
func (m validVolumeMatcher) needsNoCopy(source string, sourceType mount.Type) bool {
return sourceType == mount.TypeVolume && !m.isValid(source, sourceType, false)
}
func isHostVolumePattern(pattern string) bool {
return filepath.IsAbs(pattern) ||
strings.HasPrefix(pattern, "."+string(filepath.Separator)) ||

View File

@@ -5,7 +5,6 @@
package container
import (
"archive/tar"
"bufio"
"bytes"
"context"
@@ -17,7 +16,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
@@ -25,6 +23,7 @@ import (
"github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/network"
mobyclient "github.com/moby/moby/client"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
@@ -78,6 +77,11 @@ type mockDockerClient struct {
mock.Mock
}
func (m *mockDockerClient) ServerVersion(ctx context.Context, opts mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) {
args := m.Called(ctx, opts)
return args.Get(0).(mobyclient.ServerVersionResult), args.Error(1)
}
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
@@ -93,11 +97,6 @@ func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
}
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, containerID string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
}
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
@@ -138,6 +137,11 @@ func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.Netw
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
}
func (m *mockDockerClient) NetworkCreate(ctx context.Context, name string, opts mobyclient.NetworkCreateOptions) (mobyclient.NetworkCreateResult, error) {
args := m.Called(ctx, name, opts)
return args.Get(0).(mobyclient.NetworkCreateResult), args.Error(1)
}
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
@@ -148,12 +152,17 @@ func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mo
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
}
type endlessReader struct {
io.Reader
type interruptReader struct {
started chan struct{}
interrupted chan struct{}
stopped chan struct{}
}
func (r endlessReader) Read(_ []byte) (n int, err error) {
return 1, nil
func (r *interruptReader) Read(_ []byte) (int, error) {
close(r.started)
<-r.interrupted
close(r.stopped)
return 0, io.EOF
}
type mockConn struct {
@@ -173,16 +182,17 @@ func (m *mockConn) Close() (err error) {
func TestDockerExecAbort(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
reader := &interruptReader{started: make(chan struct{}), interrupted: make(chan struct{}), stopped: make(chan struct{})}
conn := &mockConn{}
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
conn.On("Write", []byte{3}).
Run(func(mock.Arguments) { close(reader.interrupted) }).
Return(1, nil)
client := &mockDockerClient{}
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
HijackedResponse: mobyclient.HijackedResponse{
Conn: conn,
Reader: bufio.NewReader(endlessReader{}),
},
Conn: conn,
Reader: bufio.NewReader(reader),
}, nil)
cr := &containerReference{
@@ -199,11 +209,11 @@ func TestDockerExecAbort(t *testing.T) {
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
}()
time.Sleep(500 * time.Millisecond)
<-reader.started
cancel()
err := <-channel
<-reader.stopped
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
conn.AssertExpectations(t)
@@ -218,10 +228,8 @@ func TestDockerExecFailure(t *testing.T) {
client := &mockDockerClient{}
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
HijackedResponse: mobyclient.HijackedResponse{
Conn: conn,
Reader: bufio.NewReader(strings.NewReader("output")),
},
Conn: conn,
Reader: bufio.NewReader(strings.NewReader("output")),
}, nil)
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
ExitCode: 1,
@@ -273,10 +281,8 @@ func TestDockerAttachFlushesTrailingLine(t *testing.T) {
client := &mockDockerClient{}
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
Return(mobyclient.ContainerAttachResult{
HijackedResponse: mobyclient.HijackedResponse{
Conn: &mockConn{},
Reader: bufio.NewReader(framed),
},
Conn: &mockConn{},
Reader: bufio.NewReader(framed),
}, nil)
statusCh := make(chan container.WaitResponse, 1)
@@ -341,103 +347,25 @@ func TestDockerWaitFailure(t *testing.T) {
client.AssertExpectations(t)
}
// stubStatPath answers path resolution: the given paths exist, mapped to their target
// when they are a symlink, everything else does not exist.
func stubStatPath(client *mockDockerClient, existing map[string]string) {
for containerPath, target := range existing {
client.On("ContainerStatPath", mock.Anything, "123", mobyclient.ContainerStatPathOptions{Path: containerPath}).
Return(mobyclient.ContainerStatPathResult{Stat: container.PathStat{LinkTarget: target}}, nil).Maybe()
}
client.On("ContainerStatPath", mock.Anything, "123", mock.Anything).
Return(mobyclient.ContainerStatPathResult{}, cerrdefs.ErrNotFound).Maybe()
}
// The mkdir tarball is extracted at the deepest existing ancestor, with entries relative
// to it that never traverse the "/var/run" symlink, see moby/moby#53258.
func TestDockerCopyTarStream(t *testing.T) {
ctx := context.Background()
var mkdirNames []string
client := &mockDockerClient{}
stubStatPath(client, map[string]string{"/var": "", "/var/run": "/run", "/run": ""})
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
if opts.DestinationPath != "/run" || opts.Content == nil {
return false
}
tr := tar.NewReader(opts.Content)
for hdr, err := tr.Next(); err == nil; hdr, err = tr.Next() {
mkdirNames = append(mkdirNames, hdr.Name)
}
return true
})).Return(mobyclient.CopyToContainerResult{}, nil)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
},
}
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
assert.Equal(t, []string{"act"}, mkdirNames)
client.AssertExpectations(t)
}
func TestDockerCopyTarStreamErrors(t *testing.T) {
merr := errors.New("Failure")
for _, testCase := range []struct {
name string
mkdirErr error
copyErr error
}{
{"mkdir", merr, nil},
{"copy content", nil, merr},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx := context.Background()
client := &mockDockerClient{}
stubStatPath(client, map[string]string{"/var": "", "/var/run": ""})
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, testCase.mkdirErr)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, testCase.copyErr).Maybe()
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
},
}
require.ErrorIs(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}), merr)
client.AssertExpectations(t)
})
}
}
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
// be logged as one.
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
removeFailure := errors.New("driver failed to remove root filesystem")
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
for _, tc := range []struct {
name string
err error
wantWait bool
wantFailure bool
name string
err error
wantWait bool
waitErr error
wantErr error
}{
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
{name: "wait canceled", err: cerrdefs.ErrConflict, wantWait: true, waitErr: context.Canceled, wantErr: context.Canceled},
{name: "removed during wait", err: cerrdefs.ErrConflict, wantWait: true, waitErr: cerrdefs.ErrNotFound},
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
{name: "removed cleanly", err: nil},
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
{name: "real failure", err: removeFailure, wantErr: removeFailure},
} {
t.Run(tc.name, func(t *testing.T) {
logger, hook := test.NewNullLogger()
@@ -447,21 +375,24 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
if tc.wantWait {
removed := make(chan container.WaitResponse, 1)
removed <- container.WaitResponse{}
waitErrors := make(chan error, 1)
if tc.waitErr == nil {
removed <- container.WaitResponse{}
} else {
waitErrors <- tc.waitErr
}
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
Return(mobyclient.ContainerWaitResult{Result: removed})
Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
}
cr := &containerReference{id: "abc", cli: client}
require.NoError(t, cr.remove()(ctx))
// a failure keeps the id, so a later Remove() can retry it
if tc.wantFailure {
require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
if tc.wantErr != nil {
assert.Equal(t, "abc", cr.id)
assert.Len(t, hook.AllEntries(), 1)
} else {
assert.Empty(t, cr.id)
assert.Empty(t, hook.AllEntries())
}
assert.Empty(t, hook.AllEntries())
client.AssertExpectations(t)
})
}
@@ -548,15 +479,23 @@ func TestRejectsMissingContainer(t *testing.T) {
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
check := func(op string, err error) {
t.Helper()
require.Error(t, err, op)
require.ErrorIs(t, err, ErrContainerNotFound, op)
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
}
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
check("CopyTarStream", cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
check("copyDir", cr.copyDir("/var/run/act", "/src", false, false)(ctx))
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
check("GetContainerArchive", err)
_, err = cr.Inspect(ctx)
check("Inspect", err)
// a known id the daemon has since dropped
client.On("ContainerInspect", ctx, "gone", mobyclient.ContainerInspectOptions{}).
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound)
removed := &containerReference{id: "gone", cli: client, input: &NewContainerInput{Name: "job-1"}}
_, err = removed.Inspect(ctx)
check("Inspect after removal", err)
}
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
@@ -580,43 +519,17 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
client.AssertExpectations(t)
}
// TestDockerCopyToSymlinkPath is a regression test for gitea/runner#981. Most base images
// symlink /var/run to /run, so copying into /var/run/act traverses that symlink. The broken
// docker 29.5.1 daemon fails the extraction with "mkdirat var/run: file exists" (fixed in
// 29.5.2). Running against the daemon shipped in the dind image, this catches a bad bump.
func TestDockerCopyToSymlinkPath(t *testing.T) {
requireDocker(t)
ctx := context.Background()
rc := NewContainer(&NewContainerInput{
Image: "alpine:latest",
Entrypoint: []string{"sleep", "30"},
Name: "act-test-symlink-" + time.Now().Format("20060102150405.000000"),
AutoRemove: true,
})
require.NoError(t, rc.Pull(false)(ctx))
require.NoError(t, rc.Create(nil, nil)(ctx))
require.NoError(t, rc.Start(false)(ctx))
t.Cleanup(func() {
_ = rc.Remove()(ctx)
_ = rc.Close()(ctx)
})
// CopyTarStream resolves the var/run symlink and creates act below its target, the
// exact step that fails on a broken daemon.
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
require.NoError(t, err)
}
// Type assert containerReference implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &containerReference{}
func TestCheckVolumes(t *testing.T) {
testCases := []struct {
desc string
validVolumes []string
binds []string
expectedBinds []string
desc string
validVolumes []string
binds []string
expectedBinds []string
mounts []mount.Mount
expectedMounts []mount.Mount
}{
{
desc: "match all volumes",
@@ -654,11 +567,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",
},
@@ -666,126 +582,181 @@ 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",
},
},
{
desc: "read-only rules allow only read-only mounts and copy nothing into volumes",
validVolumes: []string{"/etc/ssl:ro", "data:ro", "shared"},
binds: []string{"/etc/ssl:/ssl:ro", "/etc/ssl:/ssl", "data:/data:ro", "data:/data", "shared:/shared:ro"},
expectedBinds: []string{"/etc/ssl:/ssl:ro", "data:/data:ro,nocopy", "shared:/shared:ro"},
mounts: []mount.Mount{
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true, BindOptions: &mount.BindOptions{ReadOnlyNonRecursive: true}},
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true},
},
expectedMounts: []mount.Mount{
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true, VolumeOptions: &mount.VolumeOptions{NoCopy: true}},
},
},
{
desc: "read-only wildcard keeps writable rules writable",
validVolumes: []string{"**:ro", "shared"},
binds: []string{"/etc/ssl:/ssl:ro", "data:/data", "shared:/shared"},
expectedBinds: []string{"/etc/ssl:/ssl:ro", "shared:/shared"},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
logger, _ := test.NewNullLogger()
logger, hook := test.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
cr := &containerReference{
input: &NewContainerInput{
ValidVolumes: tc.validVolumes,
},
}
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts})
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts)
assert.Len(t, hook.AllEntries(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) // every drop is warned about
})
}
}
// A volume driver decides for itself what it mounts, e.g. the local driver with device= binds
// any host path, which valid_volumes never gets to see.
func TestMergeContainerConfigsDropsVolumeDriversFromWorkflows(t *testing.T) {
const escape = "--mount type=volume,src=job-escape,dst=/host,volume-driver=local,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=/"
hostConfig, _ := mergeOptions(t, "", escape+" --mount type=volume,src=job-plain,dst=/cache", false)
require.Len(t, hostConfig.Mounts, 1)
assert.Equal(t, "job-plain", hostConfig.Mounts[0].Source)
// the same mount from the runner's own options is the administrator's to make
hostConfig, _ = mergeOptions(t, escape, "", false)
require.Len(t, hostConfig.Mounts, 1)
assert.Equal(t, "job-escape", hostConfig.Mounts[0].Source)
}
// Both of these are read here, on the runner, so a workflow could read the runner's files
// and environment with them.
func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
hostFile := filepath.Join(t.TempDir(), "host.env")
require.NoError(t, os.WriteFile(hostFile, []byte("STOLEN=from-the-host\n"), 0o600))
t.Setenv("RUNNER_SECRET", "s3cr3t")
for _, option := range []string{"--env-file " + hostFile, "--label-file " + hostFile} {
logger, _ := test.NewNullLogger()
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
_, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
require.ErrorContains(t, err, "not allowed in a workflow")
// the runner reading its own files is what those options are for
cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}}
_, _, err = cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
require.NoError(t, err)
}
// a bare name is no longer resolved from the runner's environment, for either source
logger, _ := test.NewNullLogger()
cr := &containerReference{input: &NewContainerInput{
NetworkMode: "bridge",
RunnerOptions: "--env RUNNER_SECRET",
WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value",
}}
config, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
require.NoError(t, err)
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
}
func TestSanitizeOptionsHostConfig(t *testing.T) {
logger, _ := test.NewNullLogger()
dangerous := func() *container.HostConfig {
return &container.HostConfig{
PidMode: "host",
IpcMode: "host",
UTSMode: "host",
CgroupnsMode: "host",
UsernsMode: "host",
CapAdd: []string{"ALL"},
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
VolumesFrom: []string{"other"},
Runtime: "runc",
Resources: container.Resources{
CgroupParent: "/custom",
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
DeviceCgroupRules: []string{"a *:* rwm"},
},
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
}
// every field the sanitizer resets, so a reset dropped in a refactor fails here
hostConfig := &container.HostConfig{
PidMode: "host",
IpcMode: "host",
UTSMode: "host",
CgroupnsMode: "host",
UsernsMode: "host",
CapAdd: []string{"ALL"},
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
VolumesFrom: []string{"other"},
Runtime: "runc",
Isolation: "process",
VolumeDriver: "rogue",
MaskedPaths: []string{},
ReadonlyPaths: []string{},
CgroupParent: "/custom",
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
DeviceCgroupRules: []string{"a *:* rwm"},
DeviceRequests: []container.DeviceRequest{{Count: -1, Capabilities: [][]string{{"gpu"}}}},
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
LogConfig: container.LogConfig{Type: "syslog", Config: map[string]string{"syslog-address": "tcp://169.254.169.254:514"}},
OomScoreAdj: -1000,
OomKillDisable: new(true),
}
hostConfig := dangerous()
sanitizeOptionsHostConfig(logger, hostConfig)
sanitizeOptionsHostConfig(logger, hostConfig, &container.HostConfig{})
assert.Empty(t, string(hostConfig.PidMode))
assert.Empty(t, string(hostConfig.IpcMode))
assert.Empty(t, string(hostConfig.UTSMode))
assert.Empty(t, string(hostConfig.CgroupnsMode))
assert.Empty(t, string(hostConfig.UsernsMode))
assert.Empty(t, hostConfig.CapAdd)
assert.Empty(t, hostConfig.SecurityOpt)
assert.Empty(t, hostConfig.Devices)
assert.Empty(t, hostConfig.DeviceCgroupRules)
assert.Empty(t, hostConfig.VolumesFrom)
assert.Empty(t, hostConfig.Runtime)
assert.Empty(t, hostConfig.CgroupParent)
assert.Empty(t, hostConfig.Sysctls)
assert.Equal(t, &container.HostConfig{}, hostConfig)
}
// mergeOptions merges both option sources into a bare container, returning the result and its log.
func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *test.Hook) {
t.Helper()
logger, hook := test.NewNullLogger()
cr := &containerReference{input: &NewContainerInput{
RunnerOptions: runnerOptions,
WorkflowOptions: workflowOptions,
NetworkMode: "bridge",
UsernsMode: "private",
ValidVolumes: []string{"**"},
}}
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
Privileged: privileged,
UsernsMode: container.UsernsMode("private"),
NetworkMode: container.NetworkMode("bridge"),
})
require.NoError(t, err)
return hostConfig, hook
}
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
// OS-independent options only: --device parsing requires a linux/windows
// server OS, which is not guaranteed for the test host.
// OS-independent options only, --device and --gpus need a linux/windows server OS
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
"--security-opt apparmor=unconfined --volumes-from other " +
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
"--security-opt apparmor=unconfined --volumes-from other --isolation process " +
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1 " +
"--log-driver=syslog --log-opt syslog-address=tcp://169.254.169.254:514 " +
"--oom-score-adj=-1000 --oom-kill-disable"
t.Run("unprivileged strips host-escape options", func(t *testing.T) {
logger, _ := test.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
cr := &containerReference{
input: &NewContainerInput{
Options: dangerousOptions,
NetworkMode: "bridge",
UsernsMode: "private",
},
}
// whatever the workflow adds, an unprivileged container comes out exactly as the runner's
// own options alone describe it, field for field
for _, runnerOptions := range []string{"--shm-size 1g", dangerousOptions, "--cap-add SYS_ADMIN --security-opt seccomp=unconfined"} {
runnerOnly, _ := mergeOptions(t, runnerOptions, "", false)
withWorkflow, _ := mergeOptions(t, runnerOptions, dangerousOptions, false)
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
Privileged: false,
UsernsMode: container.UsernsMode("private"),
NetworkMode: container.NetworkMode("bridge"),
})
require.NoError(t, err)
assert.Equal(t, runnerOnly, withWorkflow, "runner options: %q", runnerOptions)
}
assert.False(t, hostConfig.Privileged)
assert.Empty(t, string(hostConfig.PidMode))
assert.Empty(t, string(hostConfig.IpcMode))
assert.Empty(t, string(hostConfig.UTSMode))
assert.Empty(t, string(hostConfig.CgroupnsMode))
// UsernsMode must keep the runner-controlled value, not the one from options.
assert.Equal(t, "private", string(hostConfig.UsernsMode))
assert.Empty(t, hostConfig.CapAdd)
assert.Empty(t, hostConfig.SecurityOpt)
assert.Empty(t, hostConfig.VolumesFrom)
assert.Empty(t, hostConfig.Runtime)
assert.Empty(t, hostConfig.CgroupParent)
assert.Empty(t, hostConfig.Sysctls)
})
// the same options from the runner reach the daemon, even --userns, which no workflow may set
kept, _ := mergeOptions(t, dangerousOptions, "", false)
assert.Equal(t, "host", string(kept.PidMode))
assert.Equal(t, []string{"ALL"}, kept.CapAdd)
assert.Equal(t, "runc", kept.Runtime)
assert.Equal(t, "host", string(kept.UsernsMode))
assert.False(t, kept.Privileged)
t.Run("privileged preserves options", func(t *testing.T) {
logger, _ := test.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
cr := &containerReference{
input: &NewContainerInput{
Options: "--pid=host --cap-add=ALL --security-opt seccomp=unconfined",
NetworkMode: "bridge",
},
}
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
Privileged: true,
NetworkMode: container.NetworkMode("bridge"),
})
require.NoError(t, err)
assert.Equal(t, "host", string(hostConfig.PidMode))
assert.Equal(t, []string{"ALL"}, hostConfig.CapAdd)
assert.Equal(t, []string{"seccomp=unconfined"}, hostConfig.SecurityOpt)
})
// privileged is the administrator opting in, so the workflow's options are honored
privileged, _ := mergeOptions(t, "", dangerousOptions, true)
assert.Equal(t, "host", string(privileged.PidMode))
assert.Equal(t, []string{"ALL"}, privileged.CapAdd)
assert.Equal(t, []string{"seccomp=unconfined", "apparmor=unconfined"}, privileged.SecurityOpt)
}
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
@@ -825,13 +796,109 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
assert.Empty(t, hostConf.Binds)
}
func TestSanitizeConfigKeepsTmpfsMounts(t *testing.T) {
hostConfig, _ := mergeOptions(t, "", "--mount type=tmpfs,dst=/var/lib/mysql", false)
require.Len(t, hostConfig.Mounts, 1)
for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} {
logger, hook := test.NewNullLogger()
_, sanitized := (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{Mounts: []mount.Mount{
hostConfig.Mounts[0],
{Type: mount.TypeVolume, Target: "/anonymous"},
{Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"},
}})
assert.Equal(t, hostConfig.Mounts, sanitized.Mounts)
assert.Len(t, hook.AllEntries(), 2)
}
}
func TestContainerInfoFromInspect(t *testing.T) {
t.Run("reports no healthcheck when the image declares none", func(t *testing.T) {
info := containerInfoFromInspect(container.InspectResponse{
ID: "abc123",
State: &container.State{Status: "running", Running: true},
})
assert.Equal(t, "abc123", info.ID)
assert.Equal(t, "running", info.State)
assert.Equal(t, HealthNone, info.Health)
assert.Empty(t, info.Ports)
})
t.Run("reports the health status and the last probe output", func(t *testing.T) {
info := containerInfoFromInspect(container.InspectResponse{
State: &container.State{
Status: "running",
Health: &container.Health{
Status: container.Unhealthy,
Log: []*container.HealthcheckResult{
{Output: "first\n"},
{Output: "connection refused\n"},
},
},
},
})
assert.Equal(t, HealthUnhealthy, info.Health)
assert.Equal(t, "connection refused", info.HealthOutput)
})
t.Run("reports the published ports", func(t *testing.T) {
info := containerInfoFromInspect(container.InspectResponse{
State: &container.State{Status: "running"},
NetworkSettings: &container.NetworkSettings{
Ports: network.PortMap{
network.MustParsePort("5432/tcp"): []network.PortBinding{{HostPort: "49153"}},
network.MustParsePort("6379/tcp"): nil,
},
},
})
assert.Equal(t, map[string]string{"5432": "49153"}, info.Ports)
})
t.Run("tolerates a container without state", func(t *testing.T) {
info := containerInfoFromInspect(container.InspectResponse{ID: "abc123"})
assert.Equal(t, "abc123", info.ID)
assert.Equal(t, HealthNone, info.Health)
})
t.Run("reports the mount sources by container path", func(t *testing.T) {
info := containerInfoFromInspect(container.InspectResponse{
Mounts: []container.MountPoint{
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"},
{Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"},
{Type: mount.TypeVolume, Name: "cache", Source: "/var/lib/docker/volumes/cache/_data", Destination: "/cache"},
{Type: mount.TypeBind, Source: "/custom/resolv.conf", Destination: "/etc/resolv.conf"},
},
HostConfig: &container.HostConfig{
Tmpfs: map[string]string{"/workspace/owner/repo//tmp/": "size=1m"},
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "cache", Target: "/cache/", VolumeOptions: &mount.VolumeOptions{Subpath: "project"}}},
},
})
assert.Equal(t, map[string]string{
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
"/workspace/owner/repo/tmp": "",
"/var/run/docker.sock": "/var/run/docker.sock",
"/cache": "/var/lib/docker/volumes/cache/_data/project",
"/etc/resolv.conf": "/custom/resolv.conf",
"/etc/hosts": "",
"/etc/hostname": "",
}, info.Mounts)
})
}
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
logger, _ := test.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
cr := &containerReference{
input: &NewContainerInput{
NetworkMode: "bridge",
Options: "--volume /host/tools:/opt/hostedtoolcache",
NetworkMode: "bridge",
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
WorkflowOptions: "--volume /unapproved:/var/run/docker.sock",
ValidVolumes: []string{"/host/tools"},
},
}
@@ -843,3 +910,38 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
assert.Empty(t, hostConf.Mounts)
}
func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
warnings := func(runnerOptions, workflowOptions string) int {
_, hook := mergeOptions(t, runnerOptions, workflowOptions, false)
return len(hook.AllEntries())
}
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) {
logger, _ := test.NewNullLogger()
cr := &containerReference{input: &NewContainerInput{
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
}}
_, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
require.NoError(t, err)
assert.Equal(t, []string{"redis", "redis-primary"}, cr.input.NetworkAliases)
}
// A dead daemon must fail the job, not panic through logrus and not silently
// drop the requested platform.
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
cli := &mockDockerClient{}
cli.On("ServerVersion", mock.Anything, mock.Anything).
Return(mobyclient.ServerVersionResult{}, errors.New("cannot connect to the Docker daemon"))
supported, err := supportsContainerImagePlatform(t.Context(), cli)
require.ErrorContains(t, err, "cannot connect to the Docker daemon")
assert.False(t, supported)
}

View File

@@ -1,138 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// Copyright 2024 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"fmt"
"os"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
)
var CommonSocketLocations = []string{
"/var/run/docker.sock",
"/run/podman/podman.sock",
"$HOME/.colima/docker.sock",
"$XDG_RUNTIME_DIR/docker.sock",
"$XDG_RUNTIME_DIR/podman/podman.sock",
`\\.\pipe\docker_engine`,
"$HOME/.docker/run/docker.sock",
}
// returns socket URI or false if not found any
func socketLocation() (string, bool) {
if dockerHost, exists := os.LookupEnv("DOCKER_HOST"); exists {
return dockerHost, true
}
for _, p := range CommonSocketLocations {
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
if strings.HasPrefix(p, `\\.\`) {
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), true
}
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), true
}
}
return "", false
}
// This function, `isDockerHostURI`, takes a string argument `daemonPath`. It checks if the
// `daemonPath` is a valid Docker host URI. It does this by checking if the scheme of the URI (the
// part before "://") contains only alphabetic characters. If it does, the function returns true,
// indicating that the `daemonPath` is a Docker host URI. If it doesn't, or if the "://" delimiter
// is not found in the `daemonPath`, the function returns false.
func isDockerHostURI(daemonPath string) bool {
if before, _, ok := strings.Cut(daemonPath, "://"); ok {
scheme := before
if strings.IndexFunc(scheme, func(r rune) bool {
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z')
}) == -1 {
return true
}
}
return false
}
type SocketAndHost struct {
Socket string
Host string
}
func GetSocketAndHost(containerSocket string) (SocketAndHost, error) {
log.Debugf("Handling container host and socket")
// Prefer DOCKER_HOST, don't override it
dockerHost, hasDockerHost := socketLocation()
socketHost := SocketAndHost{Socket: containerSocket, Host: dockerHost}
// ** socketHost.Socket cases **
// Case 1: User does _not_ want to mount a daemon socket (passes a dash)
// Case 2: User passes a filepath to the socket; is that even valid?
// Case 3: User passes a valid socket; do nothing
// Case 4: User omitted the flag; set a sane default
// ** DOCKER_HOST cases **
// Case A: DOCKER_HOST is set; use it, i.e. do nothing
// Case B: DOCKER_HOST is empty; use sane defaults
// Set host for sanity's sake, when the socket isn't useful
if !hasDockerHost && (socketHost.Socket == "-" || !isDockerHostURI(socketHost.Socket) || socketHost.Socket == "") {
// Cases: 1B, 2B, 4B
socket, found := socketLocation()
socketHost.Host = socket
hasDockerHost = found
}
// A - (dash) in socketHost.Socket means don't mount, preserve this value
// otherwise if socketHost.Socket is a filepath don't use it as socket
// Exit early if we're in an invalid state (e.g. when no DOCKER_HOST and user supplied "-", a dash or omitted)
if !hasDockerHost && socketHost.Socket != "" && !isDockerHostURI(socketHost.Socket) {
// Cases: 1B, 2B
// Should we early-exit here, since there is no host nor socket to talk to?
return SocketAndHost{}, fmt.Errorf("DOCKER_HOST was not set, couldn't be found in the usual locations, and the container daemon socket ('%s') is invalid", socketHost.Socket)
}
// Default to DOCKER_HOST if set
if socketHost.Socket == "" && hasDockerHost {
// Cases: 4A
log.Debugf("Defaulting container socket to DOCKER_HOST")
socketHost.Socket = socketHost.Host
}
// Set sane default socket location if user omitted it
if socketHost.Socket == "" {
// Cases: 4B
socket, _ := socketLocation()
// socket is empty if it isn't found, so assignment here is at worst a no-op
log.Debugf("Defaulting container socket to default '%s'", socket)
socketHost.Socket = socket
}
// Exit if both the DOCKER_HOST and socket are fulfilled
if hasDockerHost {
// Cases: 1A, 2A, 3A, 4A
if !isDockerHostURI(socketHost.Socket) {
// Cases: 1A, 2A
log.Debugf("DOCKER_HOST is set, but socket is invalid '%s'", socketHost.Socket)
}
return socketHost, nil
}
// Set a sane DOCKER_HOST default if we can
if isDockerHostURI(socketHost.Socket) {
// Cases: 3B
log.Debugf("Setting DOCKER_HOST to container socket '%s'", socketHost.Socket)
socketHost.Host = socketHost.Socket
// Both DOCKER_HOST and container socket are valid; short-circuit exit
return socketHost, nil
}
// Here there is no DOCKER_HOST _and_ the supplied container socket is not a valid URI (either invalid or a file path)
// Cases: 2B <- but is already handled at the top
// I.e. this path should never be taken
return SocketAndHost{}, fmt.Errorf("no DOCKER_HOST and an invalid container socket '%s'", socketHost.Socket)
}

View File

@@ -1,167 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// Copyright 2024 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"os"
"testing"
log "github.com/sirupsen/logrus"
assert "github.com/stretchr/testify/assert"
)
func init() {
log.SetLevel(log.DebugLevel)
}
var originalCommonSocketLocations = CommonSocketLocations
func isolateSocketEnv(t *testing.T) {
t.Helper()
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
t.Setenv("DOCKER_HOST", host)
} else {
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
}
}
func TestGetSocketAndHostWithSocket(t *testing.T) {
// Arrange
isolateSocketEnv(t)
dockerHost := "unix:///my/docker/host.sock"
socketURI := "/path/to/my.socket"
t.Setenv("DOCKER_HOST", dockerHost)
// Act
ret, err := GetSocketAndHost(socketURI)
// Assert
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
}
func TestGetSocketAndHostNoSocket(t *testing.T) {
// Arrange
dockerHost := "unix:///my/docker/host.sock"
t.Setenv("DOCKER_HOST", dockerHost)
// Act
ret, err := GetSocketAndHost("")
// Assert
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
}
func TestGetSocketAndHostOnlySocket(t *testing.T) {
// Arrange
isolateSocketEnv(t)
socketURI := "/path/to/my.socket"
os.Unsetenv("DOCKER_HOST")
defaultSocket, defaultSocketFound := socketLocation()
// Act
ret, err := GetSocketAndHost(socketURI)
// Assert
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, defaultSocketFound, "Expected to find default socket")
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
}
func TestGetSocketAndHostDontMount(t *testing.T) {
// Arrange
isolateSocketEnv(t)
dockerHost := "unix:///my/docker/host.sock"
t.Setenv("DOCKER_HOST", dockerHost)
// Act
ret, err := GetSocketAndHost("-")
// Assert
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
}
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
// Arrange
isolateSocketEnv(t)
os.Unsetenv("DOCKER_HOST")
defaultSocket, found := socketLocation()
// Act
ret, err := GetSocketAndHost("")
// Assert
assert.True(t, found, "Expected a default socket to be found")
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
}
// Catch
// > Your code breaks setting DOCKER_HOST if shouldMount is false.
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
// Arrange
isolateSocketEnv(t)
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
mySocket := mySocketFile.Name()
unixSocket := "unix://" + mySocket
defer os.RemoveAll(mySocket)
assert.NoError(t, tmpErr) //nolint:testifylint // pre-existing issue from nektos/act
os.Unsetenv("DOCKER_HOST")
CommonSocketLocations = []string{mySocket}
defaultSocket, found := socketLocation()
// Act
ret, err := GetSocketAndHost("")
// Assert
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
assert.True(t, found, "Expected default socket to be found")
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
}
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
// Arrange
isolateSocketEnv(t)
os.Unsetenv("DOCKER_HOST")
mySocket := "/my/socket/path.sock"
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
defaultSocket, found := socketLocation()
// Act
ret, err := GetSocketAndHost(mySocket)
// Assert
assert.False(t, found, "Expected no default socket to be found")
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
assert.Error(t, err, "Expected an error in invalid state")
}
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
// Arrange
isolateSocketEnv(t)
socketURI := "unix:///path/to/my.socket"
CommonSocketLocations = []string{"/unusual", "/location"}
os.Unsetenv("DOCKER_HOST")
defaultSocket, found := socketLocation()
// Act
ret, err := GetSocketAndHost(socketURI)
// Assert
// Default socket locations
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, found, "Expected no default socket to be found")
// Sane default
assert.NoError(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
}

View File

@@ -9,6 +9,7 @@ package container
import (
"context"
"errors"
"net/netip"
"runtime"
"time"
@@ -17,6 +18,10 @@ import (
"github.com/moby/moby/api/types/system"
)
func IsolatedCacheContainer(context.Context, netip.Addr, string) (string, error) {
return "", nil
}
// ImageExistsLocally returns a boolean indicating if an image with the
// requested name, tag and architecture exists in the local docker image store
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
@@ -29,6 +34,18 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
return false, errors.New("Unsupported Operation")
}
func RemoveDockerJobResources(_ context.Context, _ string) error {
return nil
}
func NewDockerProxy(_ context.Context, _ string) *DockerProxy {
return nil
}
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
return nil, errors.New("Unsupported Operation")
}
// NewDockerBuildExecutor function to create a run executor for the container
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
return func(ctx context.Context) error {
@@ -74,6 +91,30 @@ 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
}
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
return nil
}

View File

@@ -8,40 +8,67 @@ package container
import (
"context"
"errors"
"fmt"
"time"
"gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/client"
)
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
return func(ctx context.Context) error {
cli, err := GetDockerClient(ctx)
if err != nil {
return err
}
defer cli.Close()
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
if err != nil {
return err
}
for _, vol := range list.Items {
if vol.Name == volumeName {
return removeExecutor(volumeName, force)(ctx)
}
}
// Volume not found - do nothing
return nil
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
cli, err := GetDockerClient(ctx)
if err != nil {
return err
}
defer cli.Close()
for _, volumeName := range volumeNames {
if _, err := cli.VolumeCreate(ctx, client.VolumeCreateOptions{
Name: volumeName,
Labels: runnerLabels(runnerUUID),
}); err != nil {
return err
}
}
return nil
}
func removeExecutor(volume string, force bool) common.Executor {
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
cli, err := GetDockerClient(ctx)
if err != nil {
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
}
defer cli.Close()
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID).Add("dangling", "true"),
})
if err != nil {
return err
}
var errs []error
for _, item := range volumes.Items {
created, err := time.Parse(time.RFC3339, item.CreatedAt)
// an unreadable or recent timestamp may belong to a job that is still starting up
if err != nil || created.After(createdBefore) || item.Labels[runnerUUIDLabel] != runnerUUID {
continue
}
if _, err := cli.VolumeRemove(ctx, item.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", item.Name, err))
continue
}
common.Logger(ctx).Infof("removed docker volume %s left behind by an earlier job", item.Name)
}
return errors.Join(errs...)
}
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Debugf("docker volume rm %s", volume)
common.Logger(ctx).Debugf("docker volume rm %s", volumeName)
if common.Dryrun(ctx) {
return nil
@@ -53,7 +80,10 @@ func removeExecutor(volume string, force bool) common.Executor {
}
defer cli.Close()
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
_, err = cli.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
if cerrdefs.IsNotFound(err) { // already gone is the outcome we wanted
return nil
}
return err
}
}

View File

@@ -26,6 +26,7 @@ import (
"gitea.com/gitea/runner/act/lookpath"
"gitea.com/gitea/runner/internal/pkg/process"
"github.com/creack/pty"
"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"
@@ -71,12 +72,6 @@ func (e *HostEnvironment) Create(_, _ []string) common.Executor {
}
}
func (e *HostEnvironment) ConnectToNetwork(name string) common.Executor {
return func(ctx context.Context) error {
return nil
}
}
func (e *HostEnvironment) Close() common.Executor {
return func(ctx context.Context) error {
return nil
@@ -97,34 +92,7 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
}
}
func (e *HostEnvironment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
if err := os.RemoveAll(destPath); err != nil {
return err
}
tr := tar.NewReader(tarStream)
cp := &filecollector.CopyCollector{
DstDir: destPath,
}
for {
ti, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil
} else if err != nil {
return err
}
if ti.FileInfo().IsDir() {
continue
}
if ctx.Err() != nil {
return errors.New("CopyTarStream has been cancelled")
}
if err := cp.WriteFile(ti.Name, ti.FileInfo(), ti.Linkname, tr); err != nil {
return err
}
}
}
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
srcPrefix := filepath.Dir(srcPath)
@@ -142,10 +110,10 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
ignorer = gitignore.NewMatcher(ps)
}
fc := &filecollector.FileCollector{
Fs: &filecollector.DefaultFs{},
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
SkipGitDir: skipGitDir,
Handler: &filecollector.CopyCollector{
DstDir: destPath,
},
@@ -154,6 +122,14 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
}
}
func (e *HostEnvironment) DumpLogs(_ context.Context) error {
return nil
}
func (e *HostEnvironment) Inspect(_ context.Context) (*Info, error) {
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
}
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
@@ -172,7 +148,6 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
srcPrefix += string(filepath.Separator)
}
fc := &filecollector.FileCollector{
Fs: &filecollector.DefaultFs{},
SrcPath: srcPath,
SrcPrefix: srcPrefix,
Handler: tc,
@@ -238,24 +213,8 @@ func (w *ptyWriter) Write(buf []byte) (int, error) {
return w.Out.Write(buf)
}
type localEnv struct {
env map[string]string
}
func (l *localEnv) Getenv(name string) string {
if runtime.GOOS == "windows" {
for k, v := range l.env {
if strings.EqualFold(name, k) {
return v
}
}
return ""
}
return l.env[name]
}
func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) {
f, err := lookpath.LookPath2(cmd, &localEnv{env: env})
f, err := lookpath.LookPath2(cmd, env)
if err != nil {
err := "Cannot find: " + cmd + " in PATH"
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
@@ -267,7 +226,7 @@ func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string
}
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
ppty, tty, err := openPty()
ppty, tty, err := pty.Open()
if err != nil {
return nil, nil, err
}
@@ -393,8 +352,7 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
}
err = cmd.Wait()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return ExitCodeError(exitErr.ExitCode())
}
return err
@@ -672,8 +630,11 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string {
func goArchToActionArch(arch string) string {
archMapper := map[string]string{
"x86_64": "X64",
"amd64": "X64",
"386": "X86",
"arm": "ARM",
"aarch64": "ARM64",
"arm64": "ARM64",
}
if arch, ok := archMapper[arch]; ok {
return arch
@@ -683,7 +644,9 @@ func goArchToActionArch(arch string) string {
func goOsToActionOs(os string) string {
osMapper := map[string]string{
"darwin": "macOS",
"linux": "Linux",
"darwin": "macOS",
"windows": "Windows",
}
if os, ok := osMapper[os]; ok {
return os

View File

@@ -27,6 +27,11 @@ import (
// Type assert HostEnvironment implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &HostEnvironment{}
func TestActionPlatformNames(t *testing.T) {
assert.Equal(t, []string{"Linux", "macOS", "Windows"}, []string{goOsToActionOs("linux"), goOsToActionOs("darwin"), goOsToActionOs("windows")})
assert.Equal(t, []string{"X86", "X64", "ARM", "ARM64"}, []string{goArchToActionArch("386"), goArchToActionArch("amd64"), goArchToActionArch("arm"), goArchToActionArch("arm64")})
}
func TestCopyDir(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
@@ -42,10 +47,32 @@ func TestCopyDir(t *testing.T) {
_ = os.MkdirAll(e.TmpDir, 0o700)
_ = os.MkdirAll(e.ToolCache, 0o700)
_ = os.MkdirAll(e.ActPath, 0o700)
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
err := e.CopyDir(e.Workdir, e.Path, true, false)(ctx)
assert.NoError(t, err)
}
func TestCopyDirSkipGitDir(t *testing.T) {
for name, skipGitDir := range map[string]bool{"kept for a workspace copy": false, "skipped for an action copy": true} {
t.Run(name, func(t *testing.T) {
src := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
dest := filepath.Join(t.TempDir(), "dest")
e := &HostEnvironment{StdOut: os.Stdout}
require.NoError(t, e.CopyDir(dest, src+string(filepath.Separator), false, skipGitDir)(context.Background()))
assert.FileExists(t, filepath.Join(dest, "action.yml"))
if skipGitDir {
assert.NoDirExists(t, filepath.Join(dest, ".git"))
} else {
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
}
})
}
}
func TestGetContainerArchive(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()

576
act/container/kubernetes.go Normal file
View File

@@ -0,0 +1,576 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"bufio"
"bytes"
"cmp"
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"os"
"path"
"regexp"
"slices"
"strings"
"time"
"gitea.com/gitea/runner/act/common"
"github.com/kballard/go-shellquote"
"github.com/spf13/pflag"
)
// KubernetesOptions configures the pod a job runs in.
type KubernetesOptions struct {
Kubeconfig string
Namespace string
PodTemplates []map[string]any // merged in order, a later one winning
MaxLifetime time.Duration
ForcePull bool
RunnerUUID string
}
// KubernetesPod runs a job container and its service containers as one pod.
type KubernetesPod struct {
options KubernetesOptions
client *kubeClient
name, secretName string
job *kubernetesContainer
services []*kubernetesContainer
arch, path string
}
type kubernetesContainer struct {
LinuxContainerEnvironmentExtensions
pod *KubernetesPod
id string // the workflow's service id, name is its DNS label form
name string
input *NewContainerInput
probe map[string]any
}
type containerStatus struct {
Name string
Ready bool
State struct {
Waiting *struct{ Reason, Message string }
Running *struct{}
Terminated *struct {
ExitCode int
Message string
}
}
}
type podStatus struct {
Spec struct{ Containers []struct{} }
Status struct {
Phase, Reason, Message string
Conditions []struct{ Type, Status, Message string }
InitContainerStatuses, ContainerStatuses []containerStatus
}
}
func NewKubernetesPod(options KubernetesOptions) (*KubernetesPod, error) {
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
if err != nil {
return nil, fmt.Errorf("connect to Kubernetes: %w", err)
}
return &KubernetesPod{options: options, client: client}, nil
}
// JobContainer returns the container of the pod the job's steps run in.
func (p *KubernetesPod) JobContainer(input *NewContainerInput) ExecutionsEnvironment {
p.job = &kubernetesContainer{pod: p, name: "job", input: input}
return p.job
}
var invalidLabelChars = regexp.MustCompile(`[^a-z0-9-]+`)
// KubernetesServiceName returns the container name and hostname of service id, made a DNS label as both must be.
func KubernetesServiceName(id string) string {
name := strings.Trim(invalidLabelChars.ReplaceAllString(strings.ToLower(id), "-"), "-")
return strings.TrimRight(name[:min(63, len(name))], "-")
}
// ServiceContainer returns a container of the pod the job reaches at hostname KubernetesServiceName(id).
func (p *KubernetesPod) ServiceContainer(id string, input *NewContainerInput) ExecutionsEnvironment {
service := &kubernetesContainer{pod: p, id: id, name: KubernetesServiceName(id), input: input}
p.services = append(p.services, service)
return service
}
func (p *KubernetesPod) create(ctx context.Context) error {
prefix := strings.Trim(strings.ToLower(p.job.input.Name), "-")
prefix = cmp.Or(strings.TrimRight(prefix[:min(40, len(prefix))], "-"), "gitea-runner") + "-"
name := prefix + strings.ToLower(rand.Text()[:8]) // chosen here, so cleanup knows it even when a create response is lost
labels := map[string]any{"app.kubernetes.io/managed-by": "gitea-runner"}
if p.options.RunnerUUID != "" {
labels[runnerUUIDLabel] = p.options.RunnerUUID
}
metadata := map[string]any{"name": name, "labels": labels}
if hostname, err := os.Hostname(); p.client.inCluster && err == nil {
var runner struct{ Metadata struct{ Name, UID string } }
if err := p.client.do(ctx, http.MethodGet, p.client.path("pods/"+hostname), nil, &runner); err == nil {
metadata["ownerReferences"] = []any{map[string]any{"apiVersion": "v1", "kind": "Pod", "name": runner.Metadata.Name, "uid": runner.Metadata.UID}}
} else {
common.Logger(ctx).Debugf("The runner is not pod %s, job pods get no owner: %v", hostname, err)
}
}
secretData := map[string]string{}
manifest, err := p.manifest(name, secretData, metadata)
if err != nil {
return err
}
for _, service := range p.services {
if !strings.EqualFold(service.id, service.name) {
common.Logger(ctx).Warnf("Service %s is reachable as %s or localhost, as a Kubernetes hostname cannot be %q", service.id, service.name, service.id)
}
}
if len(secretData) > 0 {
p.secretName = name // named like the pod, RemoveOrphanKubernetesResources finds it by that
if err := p.client.do(ctx, http.MethodPost, p.client.path("secrets"), map[string]any{"metadata": metadata, "stringData": secretData}, nil); err != nil {
return fmt.Errorf("create secret: %w", err)
}
}
p.name = name
if err := p.client.do(ctx, http.MethodPost, p.client.path("pods"), manifest, nil); err != nil {
return fmt.Errorf("create pod: %w", err)
}
return nil
}
func (p *KubernetesPod) manifest(secret string, secretData map[string]string, metadata map[string]any) (any, error) {
template := map[string]any{"spec": map[string]any{
"automountServiceAccountToken": false,
"enableServiceLinks": false,
"terminationGracePeriodSeconds": 0, // the job container's tail ignores SIGTERM
}}
for _, layer := range p.options.PodTemplates {
template, _ = mergeManifest(template, layer, "").(map[string]any)
}
templateSpec, _ := template["spec"].(map[string]any)
templateContainers, _ := templateSpec["containers"].([]any)
templateVolumes, _ := templateSpec["volumes"].([]any)
var serviceTemplate any
if index := slices.IndexFunc(templateContainers, func(container any) bool { return manifestField(container, "name") == "$services" }); index >= 0 {
serviceTemplate = templateContainers[index]
templateSpec["containers"] = slices.Delete(slices.Clone(templateContainers), index, index+1)
}
var containers, hostnames, volumes []any
names := map[string]bool{}
for _, container := range templateContainers {
names[manifestField(container, "name")] = true
}
for _, c := range append([]*kubernetesContainer{p.job}, p.services...) {
if c != p.job && (c.name == "" || names[c.name]) {
return nil, fmt.Errorf("service %s maps to container %q, which is empty or named like another container of the pod", c.id, c.name)
}
names[c.name] = true
spec, err := c.spec(secret, secretData)
if err != nil {
return nil, err
}
if c == p.job {
containers = append(containers, spec)
continue
}
containers = append(containers, mergeManifest(serviceTemplate, spec, ""))
hostnames = append(hostnames, c.name)
}
for _, name := range []string{"workspace", "act"} {
if !slices.ContainsFunc(templateVolumes, func(volume any) bool { return manifestField(volume, "name") == name }) {
volumes = append(volumes, map[string]any{"name": name, "emptyDir": map[string]any{}})
}
}
spec := map[string]any{"restartPolicy": "Never", "containers": containers, "volumes": volumes}
if len(hostnames) > 0 {
spec["hostAliases"] = []any{map[string]any{"ip": "127.0.0.1", "hostnames": hostnames}}
}
if p.options.MaxLifetime > 0 {
spec["activeDeadlineSeconds"] = max(1, int64(p.options.MaxLifetime.Seconds()))
}
return mergeManifest(template, map[string]any{"apiVersion": "v1", "kind": "Pod", "metadata": metadata, "spec": spec}, ""), nil
}
// mergeManifest merges over onto base like a strategic merge patch: maps key by key, map list items by name or volumeMounts by mountPath, env entries, volumes and other values replaced.
func mergeManifest(base, over any, field string) any {
switch over := over.(type) {
case map[string]any:
merged := map[string]any{}
if base, ok := base.(map[string]any); ok {
maps.Copy(merged, base)
}
for key, value := range over {
merged[key] = mergeManifest(merged[key], value, key)
}
return merged
case []any:
key := cmp.Or(map[string]string{"volumeMounts": "mountPath"}[field], "name")
baseList, _ := base.([]any)
merged := slices.Clone(baseList)
for _, item := range over {
if _, ok := item.(map[string]any); !ok {
return over
}
name := manifestField(item, key)
index := slices.IndexFunc(merged, func(existing any) bool { return name != "" && manifestField(existing, key) == name })
switch {
case index < 0:
merged = append(merged, item)
case field == "env" || field == "volumes": // each holds one source, merging two makes a pod the API rejects
merged[index] = item
default:
merged[index] = mergeManifest(merged[index], item, "")
}
}
return merged
}
return over
}
func manifestField(item any, key string) string {
fields, _ := item.(map[string]any)
value, _ := fields[key].(string)
return value
}
func (c *kubernetesContainer) spec(secret string, secretData map[string]string) (map[string]any, error) {
env := []any{}
for index, entry := range c.input.Env {
key, value, _ := strings.Cut(entry, "=")
if c == c.pod.job {
env = append(env, map[string]any{"name": key, "value": value})
continue
}
secretKey := fmt.Sprintf("%s.%d", c.name, index)
secretData[secretKey] = value
env = append(env, map[string]any{"name": key, "valueFrom": map[string]any{"secretKeyRef": map[string]any{"name": secret, "key": secretKey}}})
}
spec := map[string]any{
"name": c.name, "image": c.input.Image, "command": escapeExpansion(c.input.Entrypoint), "args": escapeExpansion(c.input.Cmd),
"workingDir": c.input.WorkingDir, "env": env,
}
if c.pod.options.ForcePull {
spec["imagePullPolicy"] = "Always"
}
if c == c.pod.job {
spec["volumeMounts"] = []any{
map[string]any{"name": "workspace", "mountPath": c.input.WorkingDir},
map[string]any{"name": "act", "mountPath": c.GetActPath()},
}
}
if err := c.parseOptions(); err != nil {
return nil, fmt.Errorf("service %s: %w", c.name, err)
}
if c.probe != nil {
spec["startupProbe"] = c.probe
}
return spec, nil
}
// escapeExpansion keeps kubelet from expanding $(VAR) and $$ in command and args, which docker passes as is.
func escapeExpansion(args []string) []string {
args = slices.Clone(args)
for index := range args {
args[index] = strings.ReplaceAll(args[index], "$", "$$")
}
return args
}
// parseOptions turns docker health options, the only ones a pod supports, into a startup probe, which like docker gives up after the retries.
func (c *kubernetesContainer) parseOptions() error {
args, err := shellquote.Split(c.input.WorkflowOptions)
if err != nil {
return err
}
flags := pflag.NewFlagSet("options", pflag.ContinueOnError)
flags.SetOutput(io.Discard)
command := flags.String("health-cmd", "", "")
interval := flags.Duration("health-interval", 0, "")
timeout := flags.Duration("health-timeout", 0, "")
retries := flags.Int("health-retries", 0, "")
startPeriod := flags.Duration("health-start-period", 0, "")
if err := flags.Parse(args); err != nil {
return fmt.Errorf("options %q: %w, only --health-* options are supported on Kubernetes", c.input.WorkflowOptions, err)
}
if *command == "" {
return nil
}
period := max(1, int(cmp.Or(*interval, 30*time.Second).Seconds()))
c.probe = map[string]any{
"exec": map[string]any{"command": []string{"/bin/sh", "-c", *command}},
"periodSeconds": period,
"timeoutSeconds": max(1, int(cmp.Or(*timeout, 30*time.Second).Seconds())),
"failureThreshold": max(1, cmp.Or(*retries, 3)) + int(startPeriod.Seconds())/period, // docker does not count failures in the start period
}
return nil
}
func (p *KubernetesPod) get(ctx context.Context) (*podStatus, error) {
var pod podStatus
return &pod, p.client.do(ctx, http.MethodGet, p.client.path("pods/"+p.name), nil, &pod)
}
func (p *KubernetesPod) waitStarted(ctx context.Context) error {
var unscheduled string
for delay := 100 * time.Millisecond; ; delay = min(2*delay, time.Second) {
pod, err := p.get(ctx)
if err != nil {
return err
}
if pod.Status.Phase == "Failed" {
return fmt.Errorf("pod %s failed: %s", p.name, cmp.Or(pod.Status.Message, pod.Status.Reason))
}
started := len(pod.Status.ContainerStatuses) == len(pod.Spec.Containers)
for _, status := range append(pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses...) {
if waiting := status.State.Waiting; waiting != nil {
switch waiting.Reason {
case "ErrImagePull", "ImagePullBackOff", "InvalidImageName", "ErrImageNeverPull", "CreateContainerConfigError", "CreateContainerError", "RunContainerError":
return fmt.Errorf("container %s of pod %s: %s: %s", status.Name, p.name, waiting.Reason, waiting.Message)
}
started = false
}
}
if started {
return nil
}
for _, condition := range pod.Status.Conditions {
if condition.Type == "PodScheduled" && condition.Status == "False" && condition.Message != unscheduled {
unscheduled = condition.Message
common.Logger(ctx).Infof("Waiting for pod %s to be scheduled: %s", p.name, unscheduled)
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
}
func (c *kubernetesContainer) Create(_, _ []string) common.Executor {
return func(ctx context.Context) error {
if c != c.pod.job {
return nil
}
return c.pod.create(ctx)
}
}
func (c *kubernetesContainer) Start(_ bool) common.Executor {
return func(ctx context.Context) error {
if c != c.pod.job {
return nil
}
if err := c.pod.waitStarted(ctx); err != nil {
return err
}
var stdout bytes.Buffer
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `printf '%s\n%s' "$(uname -m)" "$PATH"`}, nil, &stdout, io.Discard); err != nil {
return err
}
machine, path, _ := strings.Cut(stdout.String(), "\n")
c.pod.arch, c.pod.path = goArchToActionArch(machine), path
return nil
}
}
func (*kubernetesContainer) Pull(bool) common.Executor {
return func(context.Context) error { return nil }
}
var (
shellName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
bashReadonly = []string{"BASHOPTS", "BASH_VERSINFO", "EUID", "PPID", "SHELLOPTS", "UID"} // bash as sh cannot export these
)
// Exec feeds the command to sh on stdin, which exports the env so it stays out of the request URL and, where sh can export a name, out of process arguments.
func (c *kubernetesContainer) Exec(command []string, env map[string]string, _, workdir string) common.Executor {
return func(ctx context.Context) error {
script, args := "", []string{"env", "--"}
for name, value := range env {
if shellName.MatchString(name) && !slices.Contains(bashReadonly, name) {
script += "command export " + shellquote.Join(name+"="+value) + "\n"
} else {
args = append(args, name+"="+value)
}
}
script += "exec " + shellquote.Join(append(args, command...)...)
if workdir != "" {
script = "cd " + shellquote.Join(workdir) + " || exit\n" + script
}
defer common.FlushWriter(c.input.Stdout)
defer common.FlushWriter(c.input.Stderr)
return c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh"}, strings.NewReader(script), c.input.Stdout, c.input.Stderr)
}
}
func (c *kubernetesContainer) extract(ctx context.Context, destPath string, write func(io.Writer) error) error {
reader, writer := io.Pipe()
defer reader.Close()
go func() { writer.CloseWithError(write(writer)) }()
var stderr bytes.Buffer
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `mkdir -p "$1" && tar -xf - -C "$1"`, "sh", destPath}, reader, io.Discard, &stderr); err != nil {
return fmt.Errorf("extract to %s: %w: %s", destPath, err, strings.TrimSpace(stderr.String()))
}
return nil
}
func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.Executor {
return func(ctx context.Context) error {
return c.extract(ctx, destPath, func(writer io.Writer) error { return writeFilesTar(ctx, writer, 0, 0, files...) })
}
}
func (c *kubernetesContainer) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
return func(ctx context.Context) error {
return c.extract(ctx, destPath, func(writer io.Writer) error {
return writeDirTar(ctx, writer, "/", srcPath, useGitIgnore, skipGitDir, 0, 0)
})
}
}
// GetContainerArchive streams the archive, checking the path first as tar writes an empty archive for a missing one.
func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
reader, writer := io.Pipe()
go func() {
var stderr bytes.Buffer
err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `test -e "$1" -o -L "$1" && exec tar -cf - -C "$2" "$3"`, "sh", srcPath, path.Dir(srcPath), path.Base(srcPath)}, nil, writer, &stderr)
if err != nil {
err = fmt.Errorf("archive %s: %w: %s", srcPath, err, strings.TrimSpace(stderr.String()))
}
writer.CloseWithError(err)
}()
buffered := bufio.NewReader(reader)
if _, err := buffered.Peek(1); err != nil {
reader.Close()
return nil, err
}
return struct {
io.Reader
io.Closer
}{buffered, reader}, nil
}
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
return parseEnvFile(c, srcPath, env)
}
func (c *kubernetesContainer) UpdateFromImageEnv(env *map[string]string) common.Executor {
return func(context.Context) error {
(*env)["PATH"] = cmp.Or((*env)["PATH"], c.pod.path)
return nil
}
}
func (c *kubernetesContainer) Inspect(ctx context.Context) (*Info, error) {
if c.pod.name == "" {
return nil, fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
}
pod, err := c.pod.get(ctx)
if isNotFound(err) {
return nil, fmt.Errorf("pod %s %w", c.pod.name, ErrContainerNotFound)
} else if err != nil {
return nil, err
}
info := &Info{ID: c.pod.name + "/" + c.name, State: "created", Health: HealthNone, Ports: map[string]string{}}
for port := range c.input.ExposedPorts {
info.Ports[port.Port()] = port.Port() // services share the pod's network
}
for _, status := range pod.Status.ContainerStatuses {
if status.Name != c.name {
continue
}
if status.State.Running != nil {
info.State = StateRunning
} else if terminated := status.State.Terminated; terminated != nil {
info.State, info.ExitCode, info.HealthOutput = "exited", terminated.ExitCode, terminated.Message
}
if c.probe != nil {
switch {
case status.Ready:
info.Health = HealthHealthy
case info.State == "exited":
info.Health = HealthUnhealthy
default:
info.Health = HealthStarting
}
}
}
return info, nil
}
func (c *kubernetesContainer) DumpLogs(ctx context.Context) error {
if c.pod.name == "" {
return fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
}
defer common.FlushWriter(c.input.Stdout)
return c.pod.client.do(ctx, http.MethodGet, c.pod.client.path("pods/"+c.pod.name+"/log?container="+c.name), nil, c.input.Stdout)
}
func (c *kubernetesContainer) Remove() common.Executor {
return func(ctx context.Context) error {
if c != c.pod.job {
return nil
}
return errors.Join(c.pod.client.delete(ctx, "pods", c.pod.name), c.pod.client.delete(ctx, "secrets", c.pod.secretName))
}
}
// RemoveOrphanKubernetesResources removes this runner's job pods created before createdBefore and the Secrets named like them, as listing Secrets would reveal all of the namespace's.
func RemoveOrphanKubernetesResources(ctx context.Context, options KubernetesOptions, createdBefore time.Time) error {
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
if err != nil {
return fmt.Errorf("connect to Kubernetes: %w", err)
}
defer client.http.CloseIdleConnections()
var pods struct {
Items []struct {
Metadata struct {
Name string
CreationTimestamp time.Time
}
}
}
if err := client.do(ctx, http.MethodGet, client.path("pods?labelSelector="+url.QueryEscape(runnerUUIDLabel+"="+options.RunnerUUID)), nil, &pods); err != nil {
return fmt.Errorf("list pods: %w", err)
}
var errs []error
for _, item := range pods.Items {
if !item.Metadata.CreationTimestamp.Before(createdBefore) {
continue
}
if err := client.delete(ctx, "secrets", item.Metadata.Name); err != nil {
errs = append(errs, err) // leave the pod for the next cleanup, it is the only way back to its Secret
continue
}
errs = append(errs, client.delete(ctx, "pods", item.Metadata.Name))
}
return errors.Join(errs...)
}
func (c *kubernetesContainer) Close() common.Executor {
return func(context.Context) error {
c.pod.client.http.CloseIdleConnections()
return nil
}
}
func (c *kubernetesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
oldStdout, oldStderr := c.input.Stdout, c.input.Stderr
c.input.Stdout, c.input.Stderr = stdout, stderr
return oldStdout, oldStderr
}
func (c *kubernetesContainer) GetRunnerContext(_ context.Context) map[string]any {
return map[string]any{"os": "Linux", "arch": c.pod.arch, "temp": "/tmp", "tool_cache": DefaultToolCache}
}

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)
}

View File

@@ -0,0 +1,300 @@
// 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"
"net/url"
"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)
actionDir := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(actionDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(actionDir, "index.js"), []byte("main()"), 0o644))
require.NoError(t, job.CopyDir(workdir+"/act/actions/sha/", actionDir+"/", false, false)(ctx))
script, err := os.ReadFile(workdir + "/act/actions/sha/index.js")
require.NoError(t, err)
assert.Equal(t, "main()", string(script))
assert.Contains(t, cluster.requests["exec"][len(cluster.requests["exec"])-1], "command="+url.QueryEscape(workdir+"/act/actions/sha/"))
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")
}

View File

@@ -46,10 +46,11 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
}
singleLineEnv := strings.Index(line, "=")
multiLineEnv := strings.Index(line, "<<")
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
switch {
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
} else if multiLineEnv != -1 {
multiLineEnvContent := ""
case multiLineEnv != -1:
var multiLineEnvContent []string
multiLineEnvDelimiter := line[multiLineEnv+2:]
delimiterFound := false
for s.Scan() {
@@ -58,10 +59,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
delimiterFound = true
break
}
if multiLineEnvContent != "" {
multiLineEnvContent += "\n"
}
multiLineEnvContent += content
multiLineEnvContent = append(multiLineEnvContent, content)
}
if err := s.Err(); err != nil {
return fmt.Errorf("reading env file: %w", err)
@@ -69,8 +67,8 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
if !delimiterFound {
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
}
localEnv[line[:multiLineEnv]] = multiLineEnvContent
} else {
localEnv[line[:multiLineEnv]] = strings.Join(multiLineEnvContent, "\n")
default:
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
}
}

View File

@@ -86,6 +86,11 @@ func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "line1\n\nline2", env["FOO"])
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n\nline2\nEOF\n"), 0o600))
env = map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "\nline2", env["FOO"])
}
func TestParseEnvFileUTF8BOM(t *testing.T) {

69
act/container/tar.go Normal file
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()
}

View File

@@ -1,17 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build (!windows && !plan9 && !openbsd) || (!windows && !plan9 && !mips64)
package container
import (
"os"
"github.com/creack/pty"
)
func openPty() (*os.File, *os.File, error) {
return pty.Open()
}

View File

@@ -1,14 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"errors"
"os"
)
func openPty() (*os.File, *os.File, error) {
return nil, nil, errors.New("Unsupported")
}

View File

@@ -1,14 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"errors"
"os"
)
func openPty() (*os.File, *os.File, error) {
return nil, nil, errors.New("Unsupported")
}

View File

@@ -1,14 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"errors"
"os"
)
func openPty() (*os.File, *os.File, error) {
return nil, nil, errors.New("Unsupported")
}

View File

@@ -1,309 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package exprparser
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"gitea.com/gitea/runner/act/model"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/rhysd/actionlint"
)
func (impl *interperterImpl) contains(search, item reflect.Value) (bool, error) {
switch search.Kind() {
case reflect.String, reflect.Int, reflect.Float64, reflect.Bool, reflect.Invalid:
return strings.Contains(
strings.ToLower(CoerceToString(search)),
strings.ToLower(CoerceToString(item)),
), nil
case reflect.Slice:
for i := 0; i < search.Len(); i++ {
arrayItem := search.Index(i).Elem()
result, err := impl.compareValues(arrayItem, item, actionlint.CompareOpNodeKindEq)
if err != nil {
return false, err
}
if isEqual, ok := result.(bool); ok && isEqual {
return true, nil
}
}
}
return false, nil
}
func (impl *interperterImpl) startsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
return strings.HasPrefix(
strings.ToLower(CoerceToString(searchString)),
strings.ToLower(CoerceToString(searchValue)),
), nil
}
func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
return strings.HasSuffix(
strings.ToLower(CoerceToString(searchString)),
strings.ToLower(CoerceToString(searchValue)),
), nil
}
const (
passThrough = iota
bracketOpen
bracketClose
)
func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
input := CoerceToString(str)
var output strings.Builder
replacementIndex := ""
state := passThrough
for _, character := range input {
switch state {
case passThrough: // normal buffer output
switch character {
case '{':
state = bracketOpen
case '}':
state = bracketClose
default:
output.WriteRune(character)
}
case bracketOpen: // found {
switch character {
case '{':
output.WriteString("{")
replacementIndex = ""
state = passThrough
case '}':
index, err := strconv.ParseInt(replacementIndex, 10, 32)
if err != nil {
return "", fmt.Errorf("The following format string is invalid: '%s'", input)
}
replacementIndex = ""
if len(replaceValue) <= int(index) {
return "", fmt.Errorf("The following format string references more arguments than were supplied: '%s'", input)
}
output.WriteString(CoerceToString(replaceValue[index]))
state = passThrough
default:
replacementIndex += string(character)
}
case bracketClose: // found }
switch character {
case '}':
output.WriteString("}")
replacementIndex = ""
state = passThrough
default:
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
}
}
}
if state != passThrough {
switch state {
case bracketOpen:
return "", fmt.Errorf("Unclosed brackets. The following format string is invalid: '%s'", input)
case bracketClose:
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
}
}
return output.String(), nil
}
func (impl *interperterImpl) join(array, sep reflect.Value) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
separator := CoerceToString(sep)
switch array.Kind() {
case reflect.Slice:
var items []string
for i := 0; i < array.Len(); i++ {
items = append(items, CoerceToString(array.Index(i)))
}
return strings.Join(items, separator), nil
default:
return strings.Join([]string{CoerceToString(array)}, separator), nil
}
}
func (impl *interperterImpl) toJSON(value reflect.Value) (string, error) {
if value.Kind() == reflect.Invalid {
return "null", nil
}
json, err := json.MarshalIndent(value.Interface(), "", " ")
if err != nil {
return "", fmt.Errorf("Cannot convert value to JSON. Cause: %v", err)
}
return string(json), nil
}
func (impl *interperterImpl) fromJSON(value reflect.Value) (any, error) {
if value.Kind() != reflect.String {
return nil, fmt.Errorf("Cannot parse non-string type %v as JSON", value.Kind())
}
var data any
err := json.Unmarshal([]byte(value.String()), &data)
if err != nil {
return nil, fmt.Errorf("Invalid JSON: %v", err)
}
return data, nil
}
func (impl *interperterImpl) hashFiles(paths ...reflect.Value) (string, error) {
var ps []gitignore.Pattern
const cwdPrefix = "." + string(filepath.Separator)
const excludeCwdPrefix = "!" + cwdPrefix
for _, path := range paths {
if path.Kind() == reflect.String {
cleanPath := path.String()
if strings.HasPrefix(cleanPath, cwdPrefix) {
cleanPath = cleanPath[len(cwdPrefix):]
} else if strings.HasPrefix(cleanPath, excludeCwdPrefix) {
cleanPath = "!" + cleanPath[len(excludeCwdPrefix):]
}
ps = append(ps, gitignore.ParsePattern(cleanPath, nil))
} else {
return "", errors.New("Non-string path passed to hashFiles")
}
}
matcher := gitignore.NewMatcher(ps)
var files []string
if err := filepath.Walk(impl.config.WorkingDir, func(path string, fi fs.FileInfo, err error) error {
if err != nil {
return err
}
sansPrefix := strings.TrimPrefix(path, impl.config.WorkingDir+string(filepath.Separator))
parts := strings.Split(sansPrefix, string(filepath.Separator))
if fi.IsDir() || !matcher.Match(parts, fi.IsDir()) {
return nil
}
files = append(files, path)
return nil
}); err != nil {
return "", fmt.Errorf("Unable to filepath.Walk: %v", err)
}
if len(files) == 0 {
return "", nil
}
hasher := sha256.New()
for _, file := range files {
f, err := os.Open(file)
if err != nil {
return "", fmt.Errorf("Unable to os.Open: %v", err)
}
if _, err := io.Copy(hasher, f); err != nil {
return "", fmt.Errorf("Unable to io.Copy: %v", err)
}
if err := f.Close(); err != nil {
return "", fmt.Errorf("Unable to Close file: %v", err)
}
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
needs := job.Needs()
for _, need := range needs {
parentNeeds := impl.getNeedsTransitive(impl.config.Run.Workflow.GetJob(need))
needs = append(needs, parentNeeds...)
}
return needs
}
func (impl *interperterImpl) always() (bool, error) {
return true, nil
}
func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
jobs := impl.config.Run.Workflow.Jobs
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
for _, needs := range jobNeeds {
if jobs[needs].NeedsResult() != "success" {
return false, nil
}
}
return true, nil
}
// jobStatus returns the current job status, treating a nil Job context as an
// empty status so status-check functions never panic on a nil dereference.
func (impl *interperterImpl) jobStatus() string {
if impl.env.Job == nil {
return ""
}
return impl.env.Job.Status
}
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
return impl.jobStatus() == "success", nil
}
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
jobs := impl.config.Run.Workflow.Jobs
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
for _, needs := range jobNeeds {
if jobs[needs].NeedsResult() == "failure" {
return true, nil
}
}
return false, nil
}
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
return impl.jobStatus() == "failure", nil
}
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
return impl.jobStatus() == "cancelled", nil
}

View File

@@ -1,283 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package exprparser
import (
"path/filepath"
"testing"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
)
func TestFunctionContains(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"contains('search', 'item') }}", false, "contains-str-str"},
{`cOnTaInS('Hello', 'll') }}`, true, "contains-str-casing"},
{`contains('HELLO', 'll') }}`, true, "contains-str-casing"},
{`contains('3.141592', 3.14) }}`, true, "contains-str-number"},
{`contains(3.141592, '3.14') }}`, true, "contains-number-str"},
{`contains(3.141592, 3.14) }}`, true, "contains-number-number"},
{`contains(true, 'u') }}`, true, "contains-bool-str"},
{`contains(null, '') }}`, true, "contains-null-str"},
{`contains(fromJSON('["first","second"]'), 'first') }}`, true, "contains-item"},
{`contains(fromJSON('[null,"second"]'), '') }}`, true, "contains-item-null-empty-str"},
{`contains(fromJSON('["","second"]'), null) }}`, true, "contains-item-empty-str-null"},
{`contains(fromJSON('[true,"second"]'), 'true') }}`, false, "contains-item-bool-arr"},
{`contains(fromJSON('["true","second"]'), true) }}`, false, "contains-item-str-bool"},
{`contains(fromJSON('[3.14,"second"]'), '3.14') }}`, true, "contains-item-number-str"},
{`contains(fromJSON('[3.14,"second"]'), 3.14) }}`, true, "contains-item-number-number"},
{`contains(fromJSON('["","second"]'), fromJSON('[]')) }}`, false, "contains-item-str-arr"},
{`contains(fromJSON('["","second"]'), fromJSON('{}')) }}`, false, "contains-item-str-obj"},
}
env := &EvaluationEnvironment{}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionStartsWith(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"startsWith('search', 'se') }}", true, "startswith-string"},
{"startsWith('search', 'sa') }}", false, "startswith-string"},
{"startsWith('123search', '123s') }}", true, "startswith-string"},
{"startsWith(123, 's') }}", false, "startswith-string"},
{"startsWith(123, '12') }}", true, "startswith-string"},
{"startsWith('123', 12) }}", true, "startswith-string"},
{"startsWith(null, '42') }}", false, "startswith-string"},
{"startsWith('null', null) }}", true, "startswith-string"},
{"startsWith('null', '') }}", true, "startswith-string"},
}
env := &EvaluationEnvironment{}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionEndsWith(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"endsWith('search', 'ch') }}", true, "endsWith-string"},
{"endsWith('search', 'sa') }}", false, "endsWith-string"},
{"endsWith('search123s', '123s') }}", true, "endsWith-string"},
{"endsWith(123, 's') }}", false, "endsWith-string"},
{"endsWith(123, '23') }}", true, "endsWith-string"},
{"endsWith('123', 23) }}", true, "endsWith-string"},
{"endsWith(null, '42') }}", false, "endsWith-string"},
{"endsWith('null', null) }}", true, "endsWith-string"},
{"endsWith('null', '') }}", true, "endsWith-string"},
}
env := &EvaluationEnvironment{}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionJoin(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"join(fromJSON('[\"a\", \"b\"]'), ',')", "a,b", "join-arr"},
{"join('string', ',')", "string", "join-str"},
{"join(1, ',')", "1", "join-number"},
{"join(null, ',')", "", "join-number"},
{"join(fromJSON('[\"a\", \"b\", null]'), null)", "ab", "join-number"},
{"join(fromJSON('[\"a\", \"b\"]'))", "a,b", "join-number"},
{"join(fromJSON('[\"a\", \"b\", null]'), 1)", "a1b1", "join-number"},
{"join(fromJSON('[1, true, null]'), '-')", "1-true-", "join-mixed-types"},
}
env := &EvaluationEnvironment{}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionToJSON(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"toJSON(env) }}", "{\n \"key\": \"value\"\n}", "toJSON"},
{"toJSON(null)", "null", "toJSON-null"},
}
env := &EvaluationEnvironment{
Env: map[string]string{
"key": "value",
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionFromJSON(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"fromJSON('{\"foo\":\"bar\"}') }}", map[string]any{
"foo": "bar",
}, "fromJSON"},
}
env := &EvaluationEnvironment{}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionHashFiles(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"hashFiles('**/non-extant-files') }}", "", "hash-non-existing-file"},
{"hashFiles('**/non-extant-files', '**/more-non-extant-files') }}", "", "hash-multiple-non-existing-files"},
{"hashFiles('./for-hashing-1.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-single-file"},
{"hashFiles('./for-hashing-*.txt') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"},
{"hashFiles('./for-hashing-*.txt', '!./for-hashing-2.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-negative-pattern"},
{"hashFiles('./for-hashing-**') }}", "c418ba693753c84115ced0da77f876cddc662b9054f4b129b90f822597ee2f94", "hash-multiple-files-and-directories"},
{"hashFiles('./for-hashing-3/**') }}", "6f5696b546a7a9d6d42a449dc9a56bef244aaa826601ef27466168846139d2c2", "hash-nested-directories"},
{"hashFiles('./for-hashing-3/**/nested-data.txt') }}", "8ecadfb49f7f978d0a9f3a957e9c8da6cc9ab871f5203b5d9f9d1dc87d8af18c", "hash-nested-directories-2"},
}
env := &EvaluationEnvironment{}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
workdir, err := filepath.Abs("testdata")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestFunctionFormat(t *testing.T) {
table := []struct {
input string
expected any
error any
name string
}{
{"format('text')", "text", nil, "format-plain-string"},
{"format('Hello {0} {1} {2}!', 'Mona', 'the', 'Octocat')", "Hello Mona the Octocat!", nil, "format-with-placeholders"},
{"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "{Hello Mona the Octocat!}", nil, "format-with-escaped-braces"},
{"format('{{0}}', 'test')", "{0}", nil, "format-with-escaped-braces"},
{"format('{{{0}}}', 'test')", "{test}", nil, "format-with-escaped-braces-and-value"},
{"format('}}')", "}", nil, "format-output-closing-brace"},
{`format('Hello "{0}" {1} {2} {3} {4}', null, true, -3.14, NaN, Infinity)`, `Hello "" true -3.14 NaN Infinity`, nil, "format-with-primitives"},
{`format('Hello "{0}" {1} {2}', fromJSON('[0, true, "abc"]'), fromJSON('[{"a":1}]'), fromJSON('{"a":{"b":1}}'))`, `Hello "Array" Array Object`, nil, "format-with-complex-types"},
{"format(true)", "true", nil, "format-with-primitive-args"},
{"format('{0}', github)", "Object", nil, "format-with-context"},
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", nil, "format-with-undefined-value"},
{"format('{0}}', '{1}', 'World')", nil, "Closing bracket without opening one. The following format string is invalid: '{0}}'", "format-invalid-format-string"},
{"format('a}b')", nil, "Closing bracket without opening one. The following format string is invalid: 'a}b'", "format-unmatched-closing-brace"},
{"format('{0', '{1}', 'World')", nil, "Unclosed brackets. The following format string is invalid: '{0'", "format-invalid-format-string"},
{"format('{2}', '{1}', 'World')", "", "The following format string references more arguments than were supplied: '{2}'", "format-invalid-replacement-reference"},
{"format('{2147483648}')", "", "The following format string is invalid: '{2147483648}'", "format-invalid-replacement-reference"},
{"format('{0} {1} {2} {3}', 1.0, 1.1, 1234567890.0, 12345678901234567890.0)", "1 1.1 1234567890 1.23456789012346E+19", nil, "format-floats"},
}
env := &EvaluationEnvironment{
Github: &model.GithubContext{},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
if tt.error != nil {
assert.Equal(t, tt.error, err.Error())
} else {
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
}
})
}
}
func TestStatusFunctionsNilJob(t *testing.T) {
// A nil Job context must not panic: the status-check functions should treat
// it as an empty status and return false rather than dereferencing nil.
env := &EvaluationEnvironment{}
table := []struct {
input string
context string
name string
}{
{"cancelled()", "job", "cancelled-nil-job"},
{"success()", "step", "step-success-nil-job"},
{"failure()", "step", "step-failure-nil-job"},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, false, output)
})
}
}

View File

@@ -1,658 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package exprparser
import (
"encoding"
"errors"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"gitea.com/gitea/runner/act/model"
"github.com/rhysd/actionlint"
)
type EvaluationEnvironment struct {
Github *model.GithubContext
Env map[string]string
Job *model.JobContext
Jobs *map[string]*model.WorkflowCallResult
Steps map[string]*model.StepResult
Runner map[string]any
Secrets map[string]string
Vars map[string]string
Strategy map[string]any
Matrix map[string]any
Needs map[string]Needs
Inputs map[string]any
HashFiles func([]reflect.Value) (any, error)
}
type Needs struct {
Outputs map[string]string `json:"outputs"`
Result string `json:"result"`
}
type Config struct {
Run *model.Run
WorkingDir string
Context string
}
type DefaultStatusCheck int
const (
DefaultStatusCheckNone DefaultStatusCheck = iota
DefaultStatusCheckSuccess
DefaultStatusCheckAlways
DefaultStatusCheckCanceled
DefaultStatusCheckFailure
)
func (dsc DefaultStatusCheck) String() string {
switch dsc {
case DefaultStatusCheckSuccess:
return "success"
case DefaultStatusCheckAlways:
return "always"
case DefaultStatusCheckCanceled:
return "cancelled"
case DefaultStatusCheckFailure:
return "failure"
}
return ""
}
type Interpreter interface {
Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error)
}
type interperterImpl struct {
env *EvaluationEnvironment
config Config
}
func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
return &interperterImpl{
env: env,
config: config,
}
}
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
input = strings.TrimPrefix(input, "${{")
if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
input = "success()"
}
parser := actionlint.NewExprParser()
exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
if err != nil {
return nil, fmt.Errorf("Failed to parse: %s", err.Message)
}
if defaultStatusCheck != DefaultStatusCheckNone {
hasStatusCheckFunction := false
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
switch strings.ToLower(funcCallNode.Callee) {
case "success", "always", "cancelled", "failure":
hasStatusCheckFunction = true
}
}
})
if !hasStatusCheckFunction {
exprNode = &actionlint.LogicalOpNode{
Kind: actionlint.LogicalOpNodeKindAnd,
Left: &actionlint.FuncCallNode{
Callee: defaultStatusCheck.String(),
Args: []actionlint.ExprNode{},
},
Right: exprNode,
}
}
}
result, err2 := impl.evaluateNode(exprNode)
return result, err2
}
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
switch node := exprNode.(type) {
case *actionlint.VariableNode:
return impl.evaluateVariable(node)
case *actionlint.BoolNode:
return node.Value, nil
case *actionlint.NullNode:
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
case *actionlint.IntNode:
return node.Value, nil
case *actionlint.FloatNode:
return node.Value, nil
case *actionlint.StringNode:
return node.Value, nil
case *actionlint.IndexAccessNode:
return impl.evaluateIndexAccess(node)
case *actionlint.ObjectDerefNode:
return impl.evaluateObjectDeref(node)
case *actionlint.ArrayDerefNode:
return impl.evaluateArrayDeref(node)
case *actionlint.NotOpNode:
return impl.evaluateNot(node)
case *actionlint.CompareOpNode:
return impl.evaluateCompare(node)
case *actionlint.LogicalOpNode:
return impl.evaluateLogicalCompare(node)
case *actionlint.FuncCallNode:
return impl.evaluateFuncCall(node)
default:
return nil, fmt.Errorf("Fatal error! Unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
}
}
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
switch strings.ToLower(variableNode.Name) {
case "github":
return impl.env.Github, nil
case "gitea": // compatible with Gitea
return impl.env.Github, nil
case "env":
return impl.env.Env, nil
case "job":
return impl.env.Job, nil
case "jobs":
if impl.env.Jobs == nil {
return nil, errors.New("Unavailable context: jobs")
}
return impl.env.Jobs, nil
case "steps":
return impl.env.Steps, nil
case "runner":
return impl.env.Runner, nil
case "secrets":
return impl.env.Secrets, nil
case "vars":
return impl.env.Vars, nil
case "strategy":
return impl.env.Strategy, nil
case "matrix":
return impl.env.Matrix, nil
case "needs":
return impl.env.Needs, nil
case "inputs":
return impl.env.Inputs, nil
case "infinity":
return math.Inf(1), nil
case "nan":
return math.NaN(), nil
default:
return nil, fmt.Errorf("Unavailable context: %s", variableNode.Name)
}
}
func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (any, error) {
left, err := impl.evaluateNode(indexAccessNode.Operand)
if err != nil {
return nil, err
}
leftValue := reflect.ValueOf(left)
right, err := impl.evaluateNode(indexAccessNode.Index)
if err != nil {
return nil, err
}
rightValue := reflect.ValueOf(right)
switch rightValue.Kind() {
case reflect.String:
return impl.getPropertyValue(leftValue, rightValue.String())
case reflect.Int:
switch leftValue.Kind() {
case reflect.Slice:
if rightValue.Int() < 0 || rightValue.Int() >= int64(leftValue.Len()) {
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
}
return leftValue.Index(int(rightValue.Int())).Interface(), nil
default:
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
}
default:
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
}
}
func (impl *interperterImpl) evaluateObjectDeref(objectDerefNode *actionlint.ObjectDerefNode) (any, error) {
left, err := impl.evaluateNode(objectDerefNode.Receiver)
if err != nil {
return nil, err
}
return impl.getPropertyValue(reflect.ValueOf(left), objectDerefNode.Property)
}
func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.ArrayDerefNode) (any, error) {
left, err := impl.evaluateNode(arrayDerefNode.Receiver)
if err != nil {
return nil, err
}
return impl.getSafeValue(reflect.ValueOf(left)), nil
}
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
switch left.Kind() {
case reflect.Pointer:
return impl.getPropertyValue(left.Elem(), property)
case reflect.Struct:
leftType := left.Type()
for field := range leftType.Fields() {
jsonName := field.Tag.Get("json")
if jsonName == property {
property = field.Name
break
}
}
fieldValue := left.FieldByNameFunc(func(name string) bool {
return strings.EqualFold(name, property)
})
if fieldValue.Kind() == reflect.Invalid {
return "", nil
}
i := fieldValue.Interface()
// The type stepStatus int is an integer, but should be treated as string
if m, ok := i.(encoding.TextMarshaler); ok {
text, err := m.MarshalText()
if err != nil {
return nil, err
}
return string(text), nil
}
return i, nil
case reflect.Map:
iter := left.MapRange()
for iter.Next() {
key := iter.Key()
switch key.Kind() {
case reflect.String:
if strings.EqualFold(key.String(), property) {
return impl.getMapValue(iter.Value())
}
default:
return nil, fmt.Errorf("'%s' in map key not implemented", key.Kind())
}
}
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
case reflect.Slice:
var values []any
for i := 0; i < left.Len(); i++ {
value, err := impl.getPropertyValue(left.Index(i).Elem(), property)
if err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
}
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
}
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
if value.Kind() == reflect.Pointer {
return impl.getMapValue(value.Elem())
}
return value.Interface(), nil
}
func (impl *interperterImpl) evaluateNot(notNode *actionlint.NotOpNode) (any, error) {
operand, err := impl.evaluateNode(notNode.Operand)
if err != nil {
return nil, err
}
return !IsTruthy(operand), nil
}
func (impl *interperterImpl) evaluateCompare(compareNode *actionlint.CompareOpNode) (any, error) {
left, err := impl.evaluateNode(compareNode.Left)
if err != nil {
return nil, err
}
right, err := impl.evaluateNode(compareNode.Right)
if err != nil {
return nil, err
}
leftValue := reflect.ValueOf(left)
rightValue := reflect.ValueOf(right)
return impl.compareValues(leftValue, rightValue, compareNode.Kind)
}
func (impl *interperterImpl) compareValues(leftValue, rightValue reflect.Value, kind actionlint.CompareOpNodeKind) (any, error) {
if leftValue.Kind() != rightValue.Kind() {
if !impl.isNumber(leftValue) {
leftValue = impl.coerceToNumber(leftValue)
}
if !impl.isNumber(rightValue) {
rightValue = impl.coerceToNumber(rightValue)
}
}
switch leftValue.Kind() {
case reflect.Bool:
return impl.compareNumber(float64(impl.coerceToNumber(leftValue).Int()), float64(impl.coerceToNumber(rightValue).Int()), kind)
case reflect.String:
return impl.compareString(strings.ToLower(leftValue.String()), strings.ToLower(rightValue.String()), kind)
case reflect.Int:
if rightValue.Kind() == reflect.Float64 {
return impl.compareNumber(float64(leftValue.Int()), rightValue.Float(), kind)
}
return impl.compareNumber(float64(leftValue.Int()), float64(rightValue.Int()), kind)
case reflect.Float64:
if rightValue.Kind() == reflect.Int {
return impl.compareNumber(leftValue.Float(), float64(rightValue.Int()), kind)
}
return impl.compareNumber(leftValue.Float(), rightValue.Float(), kind)
case reflect.Invalid:
if rightValue.Kind() == reflect.Invalid {
return true, nil
}
// not possible situation - params are converted to the same type in code above
return nil, fmt.Errorf("Compare params of Invalid type: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
default:
return nil, fmt.Errorf("Compare not implemented for types: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
}
}
func (impl *interperterImpl) coerceToNumber(value reflect.Value) reflect.Value {
switch value.Kind() {
case reflect.Invalid:
return reflect.ValueOf(0)
case reflect.Bool:
switch value.Bool() {
case true:
return reflect.ValueOf(1)
case false:
return reflect.ValueOf(0)
}
case reflect.String:
if value.String() == "" {
return reflect.ValueOf(0)
}
// try to parse the string as a number
evaluated, err := impl.Evaluate(value.String(), DefaultStatusCheckNone)
if err != nil {
return reflect.ValueOf(math.NaN())
}
if value := reflect.ValueOf(evaluated); impl.isNumber(value) {
return value
}
}
return reflect.ValueOf(math.NaN())
}
// CoerceToString converts an evaluated expression value to a string the way GitHub does,
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
func CoerceToString(v any) string {
value, ok := v.(reflect.Value)
if !ok {
value = reflect.ValueOf(v)
}
switch value.Kind() {
case reflect.Invalid:
return ""
case reflect.Bool:
return strconv.FormatBool(value.Bool())
case reflect.String:
return value.String()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(value.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(value.Uint(), 10)
case reflect.Float32, reflect.Float64:
if math.IsInf(value.Float(), 1) {
return "Infinity"
} else if math.IsInf(value.Float(), -1) {
return "-Infinity"
}
return fmt.Sprintf("%.15G", value.Float())
case reflect.Slice, reflect.Array:
return "Array"
// contexts such as `github` are pointers to structs, so they stringify as objects too
case reflect.Map, reflect.Struct:
return "Object"
case reflect.Interface, reflect.Pointer:
if value.IsNil() {
return ""
}
return CoerceToString(value.Elem())
}
return fmt.Sprintf("%v", value)
}
func (impl *interperterImpl) compareString(left, right string, kind actionlint.CompareOpNodeKind) (bool, error) {
switch kind {
case actionlint.CompareOpNodeKindLess:
return left < right, nil
case actionlint.CompareOpNodeKindLessEq:
return left <= right, nil
case actionlint.CompareOpNodeKindGreater:
return left > right, nil
case actionlint.CompareOpNodeKindGreaterEq:
return left >= right, nil
case actionlint.CompareOpNodeKindEq:
return left == right, nil
case actionlint.CompareOpNodeKindNotEq:
return left != right, nil
default:
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
}
}
func (impl *interperterImpl) compareNumber(left, right float64, kind actionlint.CompareOpNodeKind) (bool, error) {
switch kind {
case actionlint.CompareOpNodeKindLess:
return left < right, nil
case actionlint.CompareOpNodeKindLessEq:
return left <= right, nil
case actionlint.CompareOpNodeKindGreater:
return left > right, nil
case actionlint.CompareOpNodeKindGreaterEq:
return left >= right, nil
case actionlint.CompareOpNodeKindEq:
return left == right, nil
case actionlint.CompareOpNodeKindNotEq:
return left != right, nil
default:
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
}
}
func IsTruthy(input any) bool {
value := reflect.ValueOf(input)
switch value.Kind() {
case reflect.Bool:
return value.Bool()
case reflect.String:
return value.String() != ""
case reflect.Int:
return value.Int() != 0
case reflect.Float64:
if math.IsNaN(value.Float()) {
return false
}
return value.Float() != 0
case reflect.Map, reflect.Slice:
return true
default:
return false
}
}
func (impl *interperterImpl) isNumber(value reflect.Value) bool {
switch value.Kind() {
case reflect.Int, reflect.Float64:
return true
default:
return false
}
}
func (impl *interperterImpl) getSafeValue(value reflect.Value) any {
switch value.Kind() {
case reflect.Invalid:
return nil
case reflect.Float64:
if value.Float() == 0 {
return 0
}
}
return value.Interface()
}
func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.LogicalOpNode) (any, error) {
left, err := impl.evaluateNode(compareNode.Left)
if err != nil {
return nil, err
}
leftValue := reflect.ValueOf(left)
if IsTruthy(left) == (compareNode.Kind == actionlint.LogicalOpNodeKindOr) {
return impl.getSafeValue(leftValue), nil
}
right, err := impl.evaluateNode(compareNode.Right)
if err != nil {
return nil, err
}
rightValue := reflect.ValueOf(right)
switch compareNode.Kind {
case actionlint.LogicalOpNodeKindAnd:
return impl.getSafeValue(rightValue), nil
case actionlint.LogicalOpNodeKindOr:
return impl.getSafeValue(rightValue), nil
}
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
}
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
args := make([]reflect.Value, 0)
for _, arg := range funcCallNode.Args {
value, err := impl.evaluateNode(arg)
if err != nil {
return nil, err
}
args = append(args, reflect.ValueOf(value))
}
switch strings.ToLower(funcCallNode.Callee) {
case "contains":
return impl.contains(args[0], args[1])
case "startswith":
return impl.startsWith(args[0], args[1])
case "endswith":
return impl.endsWith(args[0], args[1])
case "format":
return impl.format(args[0], args[1:]...)
case "join":
if len(args) == 1 {
return impl.join(args[0], reflect.ValueOf(","))
}
return impl.join(args[0], args[1])
case "tojson":
return impl.toJSON(args[0])
case "fromjson":
return impl.fromJSON(args[0])
case "hashfiles":
if impl.env.HashFiles != nil {
return impl.env.HashFiles(args)
}
return impl.hashFiles(args...)
case "always":
return impl.always()
case "success":
if impl.config.Context == "job" {
return impl.jobSuccess()
}
if impl.config.Context == "step" {
return impl.stepSuccess()
}
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
case "failure":
if impl.config.Context == "job" {
return impl.jobFailure()
}
if impl.config.Context == "step" {
return impl.stepFailure()
}
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
case "cancelled":
return impl.cancelled()
default:
return nil, fmt.Errorf("TODO: '%s' not implemented", funcCallNode.Callee)
}
}

View File

@@ -1,684 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package exprparser
import (
"math"
"reflect"
"testing"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLiterals(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"true", true, "true"},
{"false", false, "false"},
{"null", nil, "null"},
{"123", 123, "integer"},
{"-9.7", -9.7, "float"},
{"0xff", 255, "hex"},
{"-2.99e-2", -2.99e-2, "exponential"},
{"'foo'", "foo", "string"},
{"'it''s foo'", "it's foo", "string"},
}
env := &EvaluationEnvironment{}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestOperators(t *testing.T) {
table := []struct {
input string
expected any
name string
error string
}{
{"(false || (false || true))", true, "logical-grouping", ""},
{"github.action", "push", "property-dereference", ""},
{"github['action']", "push", "property-index", ""},
{"github.action[0]", nil, "string-index", ""},
{"github.action['0']", nil, "string-index", ""},
{"fromJSON('[0,1]')[1]", 1.0, "array-index", ""},
{"fromJSON('[0,1]')[1.1]", nil, "array-index", ""},
// Disabled weird things are happening
// {"fromJSON('[0,1]')['1.1']", nil, "array-index", ""},
{"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""},
{"fromJSON('[0,1]')[2]", nil, "array-index-out-of-bounds-0", ""},
{"fromJSON('[0,1]')[34553]", nil, "array-index-out-of-bounds-1", ""},
{"fromJSON('[0,1]')[-1]", nil, "array-index-out-of-bounds-2", ""},
{"fromJSON('[0,1]')[-34553]", nil, "array-index-out-of-bounds-3", ""},
{"!true", false, "not", ""},
{"1 < 2", true, "less-than", ""},
{`'b' <= 'a'`, false, "less-than-or-equal", ""},
{"1 > 2", false, "greater-than", ""},
{`'b' >= 'a'`, true, "greater-than-or-equal", ""},
{`'a' == 'a'`, true, "equal", ""},
{`'a' != 'a'`, false, "not-equal", ""},
{`true && false`, false, "and", ""},
{`true || false`, true, "or", ""},
{`fromJSON('{}') && true`, true, "and-boolean-object", ""},
{`fromJSON('{}') || false`, make(map[string]any), "or-boolean-object", ""},
{"github.event.commits[0].author.username != github.event.commits[1].author.username", true, "property-comparison1", ""},
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username", true, "property-comparison2", ""},
{"github.event.commits[0].author.username != github.event.commits[1].author.username1", true, "property-comparison3", ""},
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username2", true, "property-comparison4", ""},
{"secrets != env", nil, "property-comparison5", "Compare not implemented for types: left: map, right: map"},
}
env := &EvaluationEnvironment{
Github: &model.GithubContext{
Action: "push",
Event: map[string]any{
"commits": []any{
map[string]any{
"author": map[string]any{
"username": "someone",
},
},
map[string]any{
"author": map[string]any{
"username": "someone-else",
},
},
},
},
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
if tt.error != "" {
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.error, err.Error())
} else {
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
}
assert.Equal(t, tt.expected, output)
})
}
}
func TestOperatorsCompare(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"!null", true, "not-null"},
{"!-10", false, "not-neg-num"},
{"!0", true, "not-zero"},
{"!3.14", false, "not-pos-float"},
{"!''", true, "not-empty-str"},
{"!'abc'", false, "not-str"},
{"!fromJSON('{}')", false, "not-obj"},
{"!fromJSON('[]')", false, "not-arr"},
{`null == 0 }}`, true, "null-coercion"},
{`true == 1 }}`, true, "boolean-coercion"},
{`'' == 0 }}`, true, "string-0-coercion"},
{`'3' == 3 }}`, true, "string-3-coercion"},
{`0 == null }}`, true, "null-coercion-alt"},
{`1 == true }}`, true, "boolean-coercion-alt"},
{`0 == '' }}`, true, "string-0-coercion-alt"},
{`3 == '3' }}`, true, "string-3-coercion-alt"},
{`'TEST' == 'test' }}`, true, "string-casing"},
{"true > false }}", true, "bool-greater-than"},
{"true >= false }}", true, "bool-greater-than-eq"},
{"true >= true }}", true, "bool-greater-than-1"},
{"true != false }}", true, "bool-not-equal"},
{`fromJSON('{}') < 2 }}`, false, "object-with-less"},
{`fromJSON('{}') < fromJSON('[]') }}`, false, "object/arr-with-lt"},
{`fromJSON('{}') > fromJSON('[]') }}`, false, "object/arr-with-gt"},
}
env := &EvaluationEnvironment{
Github: &model.GithubContext{
Action: "push",
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestOperatorsBooleanEvaluation(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
// true &&
{"true && true", true, "true-and"},
{"true && false", false, "true-and"},
{"true && null", nil, "true-and"},
{"true && -10", -10, "true-and"},
{"true && 0", 0, "true-and"},
{"true && 10", 10, "true-and"},
{"true && 3.14", 3.14, "true-and"},
{"true && 0.0", 0, "true-and"},
{"true && Infinity", math.Inf(1), "true-and"},
// {"true && -Infinity", math.Inf(-1), "true-and"},
{"true && NaN", math.NaN(), "true-and"},
{"true && ''", "", "true-and"},
{"true && 'abc'", "abc", "true-and"},
// false &&
{"false && true", false, "false-and"},
{"false && false", false, "false-and"},
{"false && null", false, "false-and"},
{"false && -10", false, "false-and"},
{"false && 0", false, "false-and"},
{"false && 10", false, "false-and"},
{"false && 3.14", false, "false-and"},
{"false && 0.0", false, "false-and"},
{"false && Infinity", false, "false-and"},
// {"false && -Infinity", false, "false-and"},
{"false && NaN", false, "false-and"},
{"false && ''", false, "false-and"},
{"false && 'abc'", false, "false-and"},
// true ||
{"true || true", true, "true-or"},
{"true || false", true, "true-or"},
{"true || null", true, "true-or"},
{"true || -10", true, "true-or"},
{"true || 0", true, "true-or"},
{"true || 10", true, "true-or"},
{"true || 3.14", true, "true-or"},
{"true || 0.0", true, "true-or"},
{"true || Infinity", true, "true-or"},
// {"true || -Infinity", true, "true-or"},
{"true || NaN", true, "true-or"},
{"true || ''", true, "true-or"},
{"true || 'abc'", true, "true-or"},
// false ||
{"false || true", true, "false-or"},
{"false || false", false, "false-or"},
{"false || null", nil, "false-or"},
{"false || -10", -10, "false-or"},
{"false || 0", 0, "false-or"},
{"false || 10", 10, "false-or"},
{"false || 3.14", 3.14, "false-or"},
{"false || 0.0", 0, "false-or"},
{"false || Infinity", math.Inf(1), "false-or"},
// {"false || -Infinity", math.Inf(-1), "false-or"},
{"false || NaN", math.NaN(), "false-or"},
{"false || ''", "", "false-or"},
{"false || 'abc'", "abc", "false-or"},
// null &&
{"null && true", nil, "null-and"},
{"null && false", nil, "null-and"},
{"null && null", nil, "null-and"},
{"null && -10", nil, "null-and"},
{"null && 0", nil, "null-and"},
{"null && 10", nil, "null-and"},
{"null && 3.14", nil, "null-and"},
{"null && 0.0", nil, "null-and"},
{"null && Infinity", nil, "null-and"},
// {"null && -Infinity", nil, "null-and"},
{"null && NaN", nil, "null-and"},
{"null && ''", nil, "null-and"},
{"null && 'abc'", nil, "null-and"},
// null ||
{"null || true", true, "null-or"},
{"null || false", false, "null-or"},
{"null || null", nil, "null-or"},
{"null || -10", -10, "null-or"},
{"null || 0", 0, "null-or"},
{"null || 10", 10, "null-or"},
{"null || 3.14", 3.14, "null-or"},
{"null || 0.0", 0, "null-or"},
{"null || Infinity", math.Inf(1), "null-or"},
// {"null || -Infinity", math.Inf(-1), "null-or"},
{"null || NaN", math.NaN(), "null-or"},
{"null || ''", "", "null-or"},
{"null || 'abc'", "abc", "null-or"},
// -10 &&
{"-10 && true", true, "neg-num-and"},
{"-10 && false", false, "neg-num-and"},
{"-10 && null", nil, "neg-num-and"},
{"-10 && -10", -10, "neg-num-and"},
{"-10 && 0", 0, "neg-num-and"},
{"-10 && 10", 10, "neg-num-and"},
{"-10 && 3.14", 3.14, "neg-num-and"},
{"-10 && 0.0", 0, "neg-num-and"},
{"-10 && Infinity", math.Inf(1), "neg-num-and"},
// {"-10 && -Infinity", math.Inf(-1), "neg-num-and"},
{"-10 && NaN", math.NaN(), "neg-num-and"},
{"-10 && ''", "", "neg-num-and"},
{"-10 && 'abc'", "abc", "neg-num-and"},
// -10 ||
{"-10 || true", -10, "neg-num-or"},
{"-10 || false", -10, "neg-num-or"},
{"-10 || null", -10, "neg-num-or"},
{"-10 || -10", -10, "neg-num-or"},
{"-10 || 0", -10, "neg-num-or"},
{"-10 || 10", -10, "neg-num-or"},
{"-10 || 3.14", -10, "neg-num-or"},
{"-10 || 0.0", -10, "neg-num-or"},
{"-10 || Infinity", -10, "neg-num-or"},
// {"-10 || -Infinity", -10, "neg-num-or"},
{"-10 || NaN", -10, "neg-num-or"},
{"-10 || ''", -10, "neg-num-or"},
{"-10 || 'abc'", -10, "neg-num-or"},
// 0 &&
{"0 && true", 0, "zero-and"},
{"0 && false", 0, "zero-and"},
{"0 && null", 0, "zero-and"},
{"0 && -10", 0, "zero-and"},
{"0 && 0", 0, "zero-and"},
{"0 && 10", 0, "zero-and"},
{"0 && 3.14", 0, "zero-and"},
{"0 && 0.0", 0, "zero-and"},
{"0 && Infinity", 0, "zero-and"},
// {"0 && -Infinity", 0, "zero-and"},
{"0 && NaN", 0, "zero-and"},
{"0 && ''", 0, "zero-and"},
{"0 && 'abc'", 0, "zero-and"},
// 0 ||
{"0 || true", true, "zero-or"},
{"0 || false", false, "zero-or"},
{"0 || null", nil, "zero-or"},
{"0 || -10", -10, "zero-or"},
{"0 || 0", 0, "zero-or"},
{"0 || 10", 10, "zero-or"},
{"0 || 3.14", 3.14, "zero-or"},
{"0 || 0.0", 0, "zero-or"},
{"0 || Infinity", math.Inf(1), "zero-or"},
// {"0 || -Infinity", math.Inf(-1), "zero-or"},
{"0 || NaN", math.NaN(), "zero-or"},
{"0 || ''", "", "zero-or"},
{"0 || 'abc'", "abc", "zero-or"},
// 10 &&
{"10 && true", true, "pos-num-and"},
{"10 && false", false, "pos-num-and"},
{"10 && null", nil, "pos-num-and"},
{"10 && -10", -10, "pos-num-and"},
{"10 && 0", 0, "pos-num-and"},
{"10 && 10", 10, "pos-num-and"},
{"10 && 3.14", 3.14, "pos-num-and"},
{"10 && 0.0", 0, "pos-num-and"},
{"10 && Infinity", math.Inf(1), "pos-num-and"},
// {"10 && -Infinity", math.Inf(-1), "pos-num-and"},
{"10 && NaN", math.NaN(), "pos-num-and"},
{"10 && ''", "", "pos-num-and"},
{"10 && 'abc'", "abc", "pos-num-and"},
// 10 ||
{"10 || true", 10, "pos-num-or"},
{"10 || false", 10, "pos-num-or"},
{"10 || null", 10, "pos-num-or"},
{"10 || -10", 10, "pos-num-or"},
{"10 || 0", 10, "pos-num-or"},
{"10 || 10", 10, "pos-num-or"},
{"10 || 3.14", 10, "pos-num-or"},
{"10 || 0.0", 10, "pos-num-or"},
{"10 || Infinity", 10, "pos-num-or"},
// {"10 || -Infinity", 10, "pos-num-or"},
{"10 || NaN", 10, "pos-num-or"},
{"10 || ''", 10, "pos-num-or"},
{"10 || 'abc'", 10, "pos-num-or"},
// 3.14 &&
{"3.14 && true", true, "pos-float-and"},
{"3.14 && false", false, "pos-float-and"},
{"3.14 && null", nil, "pos-float-and"},
{"3.14 && -10", -10, "pos-float-and"},
{"3.14 && 0", 0, "pos-float-and"},
{"3.14 && 10", 10, "pos-float-and"},
{"3.14 && 3.14", 3.14, "pos-float-and"},
{"3.14 && 0.0", 0, "pos-float-and"},
{"3.14 && Infinity", math.Inf(1), "pos-float-and"},
// {"3.14 && -Infinity", math.Inf(-1), "pos-float-and"},
{"3.14 && NaN", math.NaN(), "pos-float-and"},
{"3.14 && ''", "", "pos-float-and"},
{"3.14 && 'abc'", "abc", "pos-float-and"},
// 3.14 ||
{"3.14 || true", 3.14, "pos-float-or"},
{"3.14 || false", 3.14, "pos-float-or"},
{"3.14 || null", 3.14, "pos-float-or"},
{"3.14 || -10", 3.14, "pos-float-or"},
{"3.14 || 0", 3.14, "pos-float-or"},
{"3.14 || 10", 3.14, "pos-float-or"},
{"3.14 || 3.14", 3.14, "pos-float-or"},
{"3.14 || 0.0", 3.14, "pos-float-or"},
{"3.14 || Infinity", 3.14, "pos-float-or"},
// {"3.14 || -Infinity", 3.14, "pos-float-or"},
{"3.14 || NaN", 3.14, "pos-float-or"},
{"3.14 || ''", 3.14, "pos-float-or"},
{"3.14 || 'abc'", 3.14, "pos-float-or"},
// Infinity &&
{"Infinity && true", true, "pos-inf-and"},
{"Infinity && false", false, "pos-inf-and"},
{"Infinity && null", nil, "pos-inf-and"},
{"Infinity && -10", -10, "pos-inf-and"},
{"Infinity && 0", 0, "pos-inf-and"},
{"Infinity && 10", 10, "pos-inf-and"},
{"Infinity && 3.14", 3.14, "pos-inf-and"},
{"Infinity && 0.0", 0, "pos-inf-and"},
{"Infinity && Infinity", math.Inf(1), "pos-inf-and"},
// {"Infinity && -Infinity", math.Inf(-1), "pos-inf-and"},
{"Infinity && NaN", math.NaN(), "pos-inf-and"},
{"Infinity && ''", "", "pos-inf-and"},
{"Infinity && 'abc'", "abc", "pos-inf-and"},
// Infinity ||
{"Infinity || true", math.Inf(1), "pos-inf-or"},
{"Infinity || false", math.Inf(1), "pos-inf-or"},
{"Infinity || null", math.Inf(1), "pos-inf-or"},
{"Infinity || -10", math.Inf(1), "pos-inf-or"},
{"Infinity || 0", math.Inf(1), "pos-inf-or"},
{"Infinity || 10", math.Inf(1), "pos-inf-or"},
{"Infinity || 3.14", math.Inf(1), "pos-inf-or"},
{"Infinity || 0.0", math.Inf(1), "pos-inf-or"},
{"Infinity || Infinity", math.Inf(1), "pos-inf-or"},
// {"Infinity || -Infinity", math.Inf(1), "pos-inf-or"},
{"Infinity || NaN", math.Inf(1), "pos-inf-or"},
{"Infinity || ''", math.Inf(1), "pos-inf-or"},
{"Infinity || 'abc'", math.Inf(1), "pos-inf-or"},
// -Infinity &&
// {"-Infinity && true", true, "neg-inf-and"},
// {"-Infinity && false", false, "neg-inf-and"},
// {"-Infinity && null", nil, "neg-inf-and"},
// {"-Infinity && -10", -10, "neg-inf-and"},
// {"-Infinity && 0", 0, "neg-inf-and"},
// {"-Infinity && 10", 10, "neg-inf-and"},
// {"-Infinity && 3.14", 3.14, "neg-inf-and"},
// {"-Infinity && 0.0", 0, "neg-inf-and"},
// {"-Infinity && Infinity", math.Inf(1), "neg-inf-and"},
// {"-Infinity && -Infinity", math.Inf(-1), "neg-inf-and"},
// {"-Infinity && NaN", math.NaN(), "neg-inf-and"},
// {"-Infinity && ''", "", "neg-inf-and"},
// {"-Infinity && 'abc'", "abc", "neg-inf-and"},
// -Infinity ||
// {"-Infinity || true", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || false", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || null", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || -10", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || 0", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || 10", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || 3.14", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || 0.0", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || Infinity", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || -Infinity", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || NaN", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || ''", math.Inf(-1), "neg-inf-or"},
// {"-Infinity || 'abc'", math.Inf(-1), "neg-inf-or"},
// NaN &&
{"NaN && true", math.NaN(), "nan-and"},
{"NaN && false", math.NaN(), "nan-and"},
{"NaN && null", math.NaN(), "nan-and"},
{"NaN && -10", math.NaN(), "nan-and"},
{"NaN && 0", math.NaN(), "nan-and"},
{"NaN && 10", math.NaN(), "nan-and"},
{"NaN && 3.14", math.NaN(), "nan-and"},
{"NaN && 0.0", math.NaN(), "nan-and"},
{"NaN && Infinity", math.NaN(), "nan-and"},
// {"NaN && -Infinity", math.NaN(), "nan-and"},
{"NaN && NaN", math.NaN(), "nan-and"},
{"NaN && ''", math.NaN(), "nan-and"},
{"NaN && 'abc'", math.NaN(), "nan-and"},
// NaN ||
{"NaN || true", true, "nan-or"},
{"NaN || false", false, "nan-or"},
{"NaN || null", nil, "nan-or"},
{"NaN || -10", -10, "nan-or"},
{"NaN || 0", 0, "nan-or"},
{"NaN || 10", 10, "nan-or"},
{"NaN || 3.14", 3.14, "nan-or"},
{"NaN || 0.0", 0, "nan-or"},
{"NaN || Infinity", math.Inf(1), "nan-or"},
// {"NaN || -Infinity", math.Inf(-1), "nan-or"},
{"NaN || NaN", math.NaN(), "nan-or"},
{"NaN || ''", "", "nan-or"},
{"NaN || 'abc'", "abc", "nan-or"},
// "" &&
{"'' && true", "", "empty-str-and"},
{"'' && false", "", "empty-str-and"},
{"'' && null", "", "empty-str-and"},
{"'' && -10", "", "empty-str-and"},
{"'' && 0", "", "empty-str-and"},
{"'' && 10", "", "empty-str-and"},
{"'' && 3.14", "", "empty-str-and"},
{"'' && 0.0", "", "empty-str-and"},
{"'' && Infinity", "", "empty-str-and"},
// {"'' && -Infinity", "", "empty-str-and"},
{"'' && NaN", "", "empty-str-and"},
{"'' && ''", "", "empty-str-and"},
{"'' && 'abc'", "", "empty-str-and"},
// "" ||
{"'' || true", true, "empty-str-or"},
{"'' || false", false, "empty-str-or"},
{"'' || null", nil, "empty-str-or"},
{"'' || -10", -10, "empty-str-or"},
{"'' || 0", 0, "empty-str-or"},
{"'' || 10", 10, "empty-str-or"},
{"'' || 3.14", 3.14, "empty-str-or"},
{"'' || 0.0", 0, "empty-str-or"},
{"'' || Infinity", math.Inf(1), "empty-str-or"},
// {"'' || -Infinity", math.Inf(-1), "empty-str-or"},
{"'' || NaN", math.NaN(), "empty-str-or"},
{"'' || ''", "", "empty-str-or"},
{"'' || 'abc'", "abc", "empty-str-or"},
// "abc" &&
{"'abc' && true", true, "str-and"},
{"'abc' && false", false, "str-and"},
{"'abc' && null", nil, "str-and"},
{"'abc' && -10", -10, "str-and"},
{"'abc' && 0", 0, "str-and"},
{"'abc' && 10", 10, "str-and"},
{"'abc' && 3.14", 3.14, "str-and"},
{"'abc' && 0.0", 0, "str-and"},
{"'abc' && Infinity", math.Inf(1), "str-and"},
// {"'abc' && -Infinity", math.Inf(-1), "str-and"},
{"'abc' && NaN", math.NaN(), "str-and"},
{"'abc' && ''", "", "str-and"},
{"'abc' && 'abc'", "abc", "str-and"},
// "abc" ||
{"'abc' || true", "abc", "str-or"},
{"'abc' || false", "abc", "str-or"},
{"'abc' || null", "abc", "str-or"},
{"'abc' || -10", "abc", "str-or"},
{"'abc' || 0", "abc", "str-or"},
{"'abc' || 10", "abc", "str-or"},
{"'abc' || 3.14", "abc", "str-or"},
{"'abc' || 0.0", "abc", "str-or"},
{"'abc' || Infinity", "abc", "str-or"},
// {"'abc' || -Infinity", "abc", "str-or"},
{"'abc' || NaN", "abc", "str-or"},
{"'abc' || ''", "abc", "str-or"},
{"'abc' || 'abc'", "abc", "str-or"},
// extra tests
{"0.0 && true", 0, "float-evaluation-0-alt"},
{"-1.5 && true", true, "float-evaluation-neg-alt"},
}
env := &EvaluationEnvironment{
Github: &model.GithubContext{
Action: "push",
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
number, ok := output.(float64)
require.True(t, ok, "want a number, got %T", output)
assert.True(t, math.IsNaN(number))
} else {
assert.Equal(t, tt.expected, output)
}
})
}
}
func TestContexts(t *testing.T) {
table := []struct {
input string
expected any
name string
}{
{"github.action", "push", "github-context"},
{"github.event.commits[0].message", nil, "github-context-noexist-prop"},
{"fromjson('{\"commits\":[]}').commits[0].message", nil, "github-context-noexist-prop"},
{"github.event.pull_request.labels.*.name", nil, "github-context-noexist-prop"},
{"env.TEST", "value", "env-context"},
{"job.status", "success", "job-context"},
{"steps.step-id.outputs.name", "value", "steps-context"},
{"steps.step-id.conclusion", "success", "steps-context-conclusion"},
{"steps.step-id.conclusion && true", true, "steps-context-conclusion"},
{"steps.step-id2.conclusion", "skipped", "steps-context-conclusion"},
{"steps.step-id2.conclusion && true", true, "steps-context-conclusion"},
{"steps.step-id.outcome", "success", "steps-context-outcome"},
{"steps.step-id['outcome']", "success", "steps-context-outcome"},
{"steps.step-id.outcome == 'success'", true, "steps-context-outcome"},
{"steps.step-id['outcome'] == 'success'", true, "steps-context-outcome"},
{"steps.step-id.outcome && true", true, "steps-context-outcome"},
{"steps['step-id']['outcome'] && true", true, "steps-context-outcome"},
{"steps.step-id2.outcome", "failure", "steps-context-outcome"},
{"steps.step-id2.outcome && true", true, "steps-context-outcome"},
// Disabled, since the interpreter is still too broken
// {"contains(steps.*.outcome, 'success')", true, "steps-context-array-outcome"},
// {"contains(steps.*.outcome, 'failure')", true, "steps-context-array-outcome"},
// {"contains(steps.*.outputs.name, 'value')", true, "steps-context-array-outputs"},
{"runner.os", "Linux", "runner-context"},
{"secrets.name", "value", "secrets-context"},
{"vars.name", "value", "vars-context"},
{"strategy.fail-fast", true, "strategy-context"},
{"matrix.os", "Linux", "matrix-context"},
{"needs.job-id.outputs.output-name", "value", "needs-context"},
{"needs.job-id.result", "success", "needs-context"},
{"inputs.name", "value", "inputs-context"},
}
env := &EvaluationEnvironment{
Github: &model.GithubContext{
Action: "push",
},
Env: map[string]string{
"TEST": "value",
},
Job: &model.JobContext{
Status: "success",
},
Steps: map[string]*model.StepResult{
"step-id": {
Outputs: map[string]string{
"name": "value",
},
},
"step-id2": {
Outcome: model.StepStatusFailure,
Conclusion: model.StepStatusSkipped,
},
},
Runner: map[string]any{
"os": "Linux",
"temp": "/tmp",
"tool_cache": "/opt/hostedtoolcache",
},
Secrets: map[string]string{
"name": "value",
},
Vars: map[string]string{
"name": "value",
},
Strategy: map[string]any{
"fail-fast": true,
},
Matrix: map[string]any{
"os": "Linux",
},
Needs: map[string]Needs{
"job-id": {
Outputs: map[string]string{
"output-name": "value",
},
Result: "success",
},
},
Inputs: map[string]any{
"name": "value",
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
}
}
func TestCoerceToString(t *testing.T) {
type object struct{ Name string }
obj := object{Name: "x"}
var nilPointer *object
var nilMap map[string]any
var nilSlice []any
table := []struct {
input any
expected string
name string
}{
{nil, "", "null"},
{true, "true", "true"},
{false, "false", "false"},
{"foo", "foo", "string"},
{"", "", "empty-string"},
{123, "123", "int"},
{int64(-9), "-9", "int64"},
{uint8(7), "7", "uint8"},
{1.0, "1", "float-integral"},
{-9.7, "-9.7", "float"},
{2.99e-2, "0.0299", "float-exponential"},
{1e21, "1E+21", "float-large"},
{float32(1.5), "1.5", "float32"},
{math.NaN(), "NaN", "nan"},
{math.Inf(1), "Infinity", "positive-infinity"},
{math.Inf(-1), "-Infinity", "negative-infinity"},
{[]any{1, 2}, "Array", "slice"},
{nilSlice, "Array", "nil-slice"},
{[2]int{1, 2}, "Array", "fixed-size-array"},
{map[string]any{"a": 1}, "Object", "map"},
{nilMap, "Object", "nil-map"},
{obj, "Object", "struct"},
{&obj, "Object", "pointer-to-struct"},
{nilPointer, "", "nil-pointer"},
{&model.GithubContext{Action: "push"}, "Object", "github-context"},
{reflect.ValueOf(42), "42", "reflected-value"},
{reflect.Value{}, "", "invalid-reflected-value"},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, CoerceToString(tt.input))
})
}
}

View File

@@ -1 +0,0 @@
Hello

View File

@@ -1 +0,0 @@
World!

View File

@@ -1 +0,0 @@
Knock knock!

View File

@@ -1 +0,0 @@
Anybody home?

View File

@@ -94,58 +94,29 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
}
type FileCollector struct {
Ignorer gitignore.Matcher
SrcPath string
SrcPrefix string
Fs Fs
Handler Handler
Ignorer gitignore.Matcher
SrcPath string
SrcPrefix string
SkipGitDir bool
Handler Handler
}
type Fs interface {
Walk(root string, fn filepath.WalkFunc) error
OpenGitIndex(path string) (*index.Index, error)
Open(path string) (io.ReadCloser, error)
Readlink(path string) (string, error)
}
type DefaultFs struct{}
func (*DefaultFs) Walk(root string, fn filepath.WalkFunc) error {
return filepath.Walk(root, fn)
}
func (*DefaultFs) OpenGitIndex(path string) (*index.Index, error) {
r, err := git.PlainOpen(path)
func openGitIndex(path string) (*index.Index, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return nil, err
}
i, err := r.Storer.Index()
if err != nil {
return nil, err
}
return i, nil
}
func (*DefaultFs) Open(path string) (io.ReadCloser, error) {
return os.Open(path)
}
func (*DefaultFs) Readlink(path string) (string, error) {
return os.Readlink(path)
return repo.Storer.Index()
}
func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []string) filepath.WalkFunc {
i, _ := fc.Fs.OpenGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
i, _ := openGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
return func(file string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if ctx != nil {
select {
case <-ctx.Done():
return errors.New("copy cancelled")
default:
}
if ctx != nil && ctx.Err() != nil {
return errors.New("copy cancelled")
}
sansPrefix := strings.TrimPrefix(file, fc.SrcPrefix)
@@ -154,6 +125,12 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
return nil
}
if fc.SkipGitDir && split[len(split)-1] == ".git" {
if fi.IsDir() {
return filepath.SkipDir
}
return nil
}
var entry *index.Entry
if i != nil {
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
@@ -175,7 +152,7 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
}
}
if err == nil && entry.Mode == filemode.Submodule {
err = fc.Fs.Walk(file, fc.CollectFiles(ctx, split))
err = filepath.Walk(file, fc.CollectFiles(ctx, split))
if err != nil {
return err
}
@@ -185,7 +162,7 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
// return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update)
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
linkName, err := fc.Fs.Readlink(file)
linkName, err := os.Readlink(file)
if err != nil {
return fmt.Errorf("unable to readlink '%s': %w", file, err)
}
@@ -195,23 +172,15 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
}
// open file
f, err := fc.Fs.Open(file)
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
if ctx != nil {
// make io.Copy cancellable by closing the file
cpctx, cpfinish := context.WithCancel(ctx)
defer cpfinish()
go func() {
select {
case <-cpctx.Done():
case <-ctx.Done():
f.Close()
}
}()
stop := context.AfterFunc(ctx, func() { _ = f.Close() })
defer stop()
}
return fc.Handler.WriteFile(path, fi, "", f)

View File

@@ -6,6 +6,7 @@ package filecollector
import (
"archive/tar"
"bytes"
"context"
"io"
"os"
@@ -13,110 +14,41 @@ import (
"runtime"
"strings"
"testing"
"time"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/go-git/go-git/v5/plumbing/format/index"
"github.com/go-git/go-git/v5/storage/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type memoryFs struct {
billy.Filesystem
}
func (mfs *memoryFs) walk(root string, fn filepath.WalkFunc) error {
dir, err := mfs.ReadDir(root)
if err != nil {
return err
}
for i := range dir {
filename := filepath.Join(root, dir[i].Name())
err = fn(filename, dir[i], nil)
if dir[i].IsDir() {
if err == filepath.SkipDir {
err = nil
} else if err := mfs.walk(filename, fn); err != nil {
return err
}
}
if err != nil {
return err
}
}
return nil
}
func (mfs *memoryFs) Walk(root string, fn filepath.WalkFunc) error {
stat, err := mfs.Lstat(root)
if err != nil {
return err
}
err = fn(strings.Join([]string{root, "."}, string(filepath.Separator)), stat, nil)
if err != nil {
return err
}
return mfs.walk(root, fn)
}
func (mfs *memoryFs) OpenGitIndex(path string) (*index.Index, error) {
f, _ := mfs.Filesystem.Chroot(filepath.Join(path, ".git")) //nolint:staticcheck // pre-existing issue from nektos/act
storage := filesystem.NewStorage(f, cache.NewObjectLRUDefault())
i, err := storage.Index()
if err != nil {
return nil, err
}
return i, nil
}
func (mfs *memoryFs) Open(path string) (io.ReadCloser, error) {
return mfs.Filesystem.Open(path)
}
func (mfs *memoryFs) Readlink(path string) (string, error) {
return mfs.Filesystem.Readlink(path)
}
func TestIgnoredTrackedfile(t *testing.T) {
fs := memfs.New()
_ = fs.MkdirAll("mygitrepo/.git", 0o777)
dotgit, _ := fs.Chroot("mygitrepo/.git")
worktree, _ := fs.Chroot("mygitrepo")
repo, _ := git.Init(filesystem.NewStorage(dotgit, cache.NewObjectLRUDefault()), worktree)
f, _ := worktree.Create(".gitignore")
_, _ = f.Write([]byte(".*\n"))
f.Close()
// This file shouldn't be in the tar
f, _ = worktree.Create(".env")
_, _ = f.Write([]byte("test=val1\n"))
f.Close()
w, _ := repo.Worktree()
// .gitignore is in the tar after adding it to the index
_, _ = w.Add(".gitignore")
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
repo, err := git.PlainInit(repoDir, false)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".gitignore"), []byte(".*\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".env"), []byte("test=val1\n"), 0o644))
worktree, err := repo.Worktree()
require.NoError(t, err)
_, err = worktree.Add(".gitignore")
require.NoError(t, err)
tmpTar, _ := fs.Create("temp.tar")
tw := tar.NewWriter(tmpTar)
ps, _ := gitignore.ReadPatterns(worktree, []string{})
ignorer := gitignore.NewMatcher(ps)
var archive bytes.Buffer
tw := tar.NewWriter(&archive)
patterns, err := gitignore.ReadPatterns(worktree.Filesystem, nil)
require.NoError(t, err)
ignorer := gitignore.NewMatcher(patterns)
fc := &FileCollector{
Fs: &memoryFs{Filesystem: fs},
Ignorer: ignorer,
SrcPath: "mygitrepo",
SrcPrefix: "mygitrepo" + string(filepath.Separator),
SrcPath: repoDir,
SrcPrefix: repoDir + string(filepath.Separator),
Handler: &TarCollector{
TarWriter: tw,
},
}
err := fc.Fs.Walk("mygitrepo", fc.CollectFiles(context.Background(), []string{}))
assert.NoError(t, err, "successfully collect files") //nolint:testifylint // pre-existing issue from nektos/act
tw.Close()
_, _ = tmpTar.Seek(0, io.SeekStart)
tr := tar.NewReader(tmpTar)
err = filepath.Walk(repoDir, fc.CollectFiles(context.Background(), nil))
assert.NoError(t, err, "successfully collect files")
require.NoError(t, tw.Close())
tr := tar.NewReader(&archive)
h, err := tr.Next()
assert.NoError(t, err, "tar must not be empty") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, ".gitignore", h.Name)
@@ -125,47 +57,32 @@ func TestIgnoredTrackedfile(t *testing.T) {
}
func TestSymlinks(t *testing.T) {
fs := memfs.New()
_ = fs.MkdirAll("mygitrepo/.git", 0o777)
dotgit, _ := fs.Chroot("mygitrepo/.git")
worktree, _ := fs.Chroot("mygitrepo")
repo, _ := git.Init(filesystem.NewStorage(dotgit, cache.NewObjectLRUDefault()), worktree)
// This file shouldn't be in the tar
f, err := worktree.Create(".env")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
_, err = f.Write([]byte("test=val1\n"))
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
f.Close()
err = worktree.Symlink(".env", "test.env")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
if runtime.GOOS == "windows" {
t.Skip("creating symlinks requires elevated privileges on Windows")
}
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
repo, err := git.PlainInit(repoDir, false)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".env"), []byte("test=val1\n"), 0o644))
require.NoError(t, os.Symlink(".env", filepath.Join(repoDir, "test.env")))
worktree, err := repo.Worktree()
require.NoError(t, err)
_, err = worktree.Add("test.env")
require.NoError(t, err)
w, err := repo.Worktree()
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// .gitignore is in the tar after adding it to the index
_, err = w.Add(".env")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
_, err = w.Add("test.env")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
tmpTar, _ := fs.Create("temp.tar")
tw := tar.NewWriter(tmpTar)
ps, _ := gitignore.ReadPatterns(worktree, []string{})
ignorer := gitignore.NewMatcher(ps)
var archive bytes.Buffer
tw := tar.NewWriter(&archive)
fc := &FileCollector{
Fs: &memoryFs{Filesystem: fs},
Ignorer: ignorer,
SrcPath: "mygitrepo",
SrcPrefix: "mygitrepo" + string(filepath.Separator),
SrcPath: repoDir,
SrcPrefix: repoDir + string(filepath.Separator),
Handler: &TarCollector{
TarWriter: tw,
},
}
err = fc.Fs.Walk("mygitrepo", fc.CollectFiles(context.Background(), []string{}))
assert.NoError(t, err, "successfully collect files") //nolint:testifylint // pre-existing issue from nektos/act
tw.Close()
_, _ = tmpTar.Seek(0, io.SeekStart)
tr := tar.NewReader(tmpTar)
err = filepath.Walk(repoDir, fc.CollectFiles(context.Background(), nil))
assert.NoError(t, err, "successfully collect files")
require.NoError(t, tw.Close())
tr := tar.NewReader(&archive)
h, err := tr.Next()
files := map[string]tar.Header{}
for err == nil {
@@ -223,62 +140,14 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
assert.Equal(t, "target", resolved)
}
func TestDefaultFsOpenReadlinkAndWalk(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating symlinks requires elevated privileges on Windows")
}
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644))
require.NoError(t, os.Symlink("file.txt", filepath.Join(root, "link.txt")))
fsys := &DefaultFs{}
var walked []string
require.NoError(t, fsys.Walk(root, func(path string, info os.FileInfo, err error) error {
require.NoError(t, err)
walked = append(walked, info.Name())
return nil
}))
require.Contains(t, walked, "file.txt")
require.Contains(t, walked, "link.txt")
file, err := fsys.Open(filepath.Join(root, "file.txt"))
require.NoError(t, err)
data, err := io.ReadAll(file)
require.NoError(t, err)
require.NoError(t, file.Close())
require.Equal(t, "content", string(data))
link, err := fsys.Readlink(filepath.Join(root, "link.txt"))
require.NoError(t, err)
require.Equal(t, "file.txt", link)
}
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
fc := &FileCollector{Fs: &memoryFs{Filesystem: memfs.New()}}
walk := fc.CollectFiles(cancelledContext(t), nil)
ctx, cancel := context.WithCancel(t.Context())
cancel()
walk := (&FileCollector{}).CollectFiles(ctx, nil)
err := walk("file", fakeFileInfo{name: "file"}, nil)
err := walk("file", nil, nil)
require.EqualError(t, err, "copy cancelled")
err = walk("file", fakeFileInfo{name: "file"}, os.ErrPermission)
err = walk("file", nil, os.ErrPermission)
require.ErrorIs(t, err, os.ErrPermission)
}
func cancelledContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}
type fakeFileInfo struct {
name string
}
func (f fakeFileInfo) Name() string { return f.name }
func (f fakeFileInfo) Size() int64 { return 0 }
func (f fakeFileInfo) Mode() os.FileMode { return 0o644 }
func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
func (f fakeFileInfo) IsDir() bool { return false }
func (f fakeFileInfo) Sys() any { return nil }

View File

@@ -0,0 +1,143 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2021 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package ghcontext fills a model.GithubContext from the local git checkout.
// The pure data parts of the context live in the shared
// gitea.dev/actionslib/pkg/model package, only the helpers that need a
// git repository on disk are kept here.
package ghcontext
import (
"context"
"fmt"
"strings"
"sync"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
"gitea.dev/actionslib/pkg/model"
)
var (
findGitRef = git.FindGitRef
findGitRevision = git.FindGitRevision
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) {
logger := common.Logger(ctx)
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
switch ghc.EventName {
case "pull_request_target":
ghc.Ref = "refs/heads/" + ghc.BaseRef
case "pull_request", "pull_request_review", "pull_request_review_comment":
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
case "deployment", "deployment_status":
ghc.Ref = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "ref"))
case "release":
ghc.Ref = "refs/tags/" + model.AsString(model.NestedMapLookup(ghc.Event, "release", "tag_name"))
case "push", "create", "workflow_dispatch":
ghc.Ref = model.AsString(ghc.Event["ref"])
default:
defaultBranch := model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
if defaultBranch != "" {
ghc.Ref = "refs/heads/" + defaultBranch
}
}
if ghc.Ref == "" {
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 {
logger.Debugf("using github ref: %s", ref)
ghc.Ref = ref
}
repository, exists := ghc.Event["repository"]
if !exists {
repository = map[string]any{}
}
if repository, ok := repository.(map[string]any); !ok {
logger.Warn("unable to set default branch to master")
} else if _, exists := repository["default_branch"]; !exists {
repository["default_branch"] = "master"
ghc.Event["repository"] = repository
}
if ghc.Ref == "" {
ghc.Ref = "refs/heads/" + model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
}
}
}
// SetSha resolves the commit of the context from its event payload, falling
// back to the revision checked out in repoPath.
func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
logger := common.Logger(ctx)
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
switch ghc.EventName {
case "pull_request_target":
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
case "deployment", "deployment_status":
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "sha"))
case "push", "create", "workflow_dispatch":
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
ghc.Sha = model.AsString(ghc.Event["after"])
}
}
if ghc.Sha == "" {
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 {
ghc.Sha = sha
}
}
}
// SetRepositoryAndOwner resolves the repository of the context from the git
// remote in repoPath when it is not set yet, and derives its owner.
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, githubInstance, repoPath string) {
if ghc.Repository == "" {
repo, err := findGithubRepo(ctx, repoPath, githubInstance)
if err != nil {
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v, repoPath: %v): %v", githubInstance, repoPath, err)
return
}
ghc.Repository = repo
}
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
}

View File

@@ -2,13 +2,14 @@
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
package ghcontext
import (
"context"
"errors"
"testing"
"gitea.dev/actionslib/pkg/model"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
@@ -97,13 +98,13 @@ func TestSetRef(t *testing.T) {
for _, table := range tables {
t.Run(table.eventName, func(t *testing.T) {
ghc := &GithubContext{
ghc := &model.GithubContext{
EventName: table.eventName,
BaseRef: "master",
Event: table.event,
}
ghc.SetRef(context.Background(), "main", "/some/dir")
SetRef(context.Background(), ghc, "/some/dir")
ghc.SetRefTypeAndName()
assert.Equal(t, table.ref, ghc.Ref)
@@ -116,12 +117,12 @@ func TestSetRef(t *testing.T) {
return "", errors.New("no default branch")
}
ghc := &GithubContext{
ghc := &model.GithubContext{
EventName: "no-default-branch",
Event: map[string]any{},
}
ghc.SetRef(context.Background(), "", "/some/dir")
SetRef(context.Background(), ghc, "/some/dir")
assert.Equal(t, "refs/heads/master", ghc.Ref)
})
@@ -202,15 +203,28 @@ func TestSetSha(t *testing.T) {
for _, table := range tables {
t.Run(table.eventName, func(t *testing.T) {
ghc := &GithubContext{
ghc := &model.GithubContext{
EventName: table.eventName,
BaseRef: "master",
Event: table.event,
}
ghc.SetSha(context.Background(), "/some/dir")
SetSha(context.Background(), ghc, "/some/dir")
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)
}
})
}

View File

@@ -4,6 +4,18 @@
package lookpath
type Env interface {
Getenv(name string) string
import (
"runtime"
"strings"
)
func getenv(env map[string]string, name string) string {
if runtime.GOOS == "windows" {
for key, value := range env {
if strings.EqualFold(name, key) {
return value
}
}
}
return env[name]
}

View File

@@ -18,7 +18,7 @@ var ErrNotFound = errors.New("executable file not found in $PATH")
// directories named by the PATH environment variable.
// If file contains a slash, it is tried directly and the PATH is not consulted.
// The result may be an absolute path or a path relative to the current directory.
func LookPath2(file string, lenv Env) (string, error) {
func LookPath2(file string, _ map[string]string) (string, error) {
// Wasm can not execute processes, so act as if there are no executables at all.
return "", &Error{file, ErrNotFound}
}

View File

@@ -32,7 +32,7 @@ func findExecutable(file string) error {
// If file begins with "/", "#", "./", or "../", it is tried
// directly and the path is not consulted.
// The result may be an absolute path or a path relative to the current directory.
func LookPath2(file string, lenv Env) (string, error) {
func LookPath2(file string, env map[string]string) (string, error) {
// skip the path lookup for these prefixes
skip := []string{"/", "#", "./", "../"}
@@ -46,7 +46,7 @@ func LookPath2(file string, lenv Env) (string, error) {
}
}
path := lenv.Getenv("path")
path := getenv(env, "path")
for _, dir := range filepath.SplitList(path) {
path := filepath.Join(dir, file)
if err := findExecutable(path); err == nil {

View File

@@ -33,7 +33,7 @@ func findExecutable(file string) error {
// directories named by the PATH environment variable.
// If file contains a slash, it is tried directly and the PATH is not consulted.
// The result may be an absolute path or a path relative to the current directory.
func LookPath2(file string, lenv Env) (string, error) {
func LookPath2(file string, env map[string]string) (string, error) {
// NOTE(rsc): I wish we could use the Plan 9 behavior here
// (only bypass the path if file begins with / or ./ or ../)
// but that would not match all the Unix shells.
@@ -45,7 +45,7 @@ func LookPath2(file string, lenv Env) (string, error) {
}
return "", &Error{file, err}
}
path := lenv.Getenv("PATH")
path := getenv(env, "PATH")
for _, dir := range filepath.SplitList(path) {
if dir == "" {
// Unix shell semantics: path element "" means "."

View File

@@ -13,12 +13,6 @@ import (
"testing"
)
type testEnv map[string]string
func (e testEnv) Getenv(name string) string {
return e[name]
}
func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "tool")
@@ -26,7 +20,7 @@ func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
t.Fatal(err)
}
got, err := LookPath2("tool", testEnv{"PATH": string(filepath.ListSeparator) + dir})
got, err := LookPath2("tool", map[string]string{"PATH": string(filepath.ListSeparator) + dir})
if err != nil {
t.Fatal(err)
}
@@ -42,7 +36,7 @@ func TestLookPath2DirectPathDoesNotSearchPath(t *testing.T) {
t.Fatal(err)
}
got, err := LookPath2(exe, testEnv{"PATH": ""})
got, err := LookPath2(exe, map[string]string{"PATH": ""})
if err != nil {
t.Fatal(err)
}
@@ -58,7 +52,7 @@ func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
t.Fatal(err)
}
_, err := LookPath2(file, testEnv{"PATH": dir})
_, err := LookPath2(file, map[string]string{"PATH": dir})
var pathErr *Error
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, fs.ErrPermission) {
t.Fatalf("LookPath2(non-executable) error = %v, want fs.ErrPermission wrapped in *Error", err)
@@ -67,7 +61,7 @@ func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
t.Fatalf("Error() = %q, want %q", pathErr.Error(), fs.ErrPermission.Error())
}
_, err = LookPath2("missing", testEnv{"PATH": dir})
_, err = LookPath2("missing", map[string]string{"PATH": dir})
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, ErrNotFound) {
t.Fatalf("LookPath2(missing) error = %v, want ErrNotFound wrapped in *Error", err)
}

View File

@@ -58,9 +58,9 @@ func findExecutable(file string, exts []string) (string, error) {
// LookPath also uses PATHEXT environment variable to match
// a suitable candidate.
// The result may be an absolute path or a path relative to the current directory.
func LookPath2(file string, lenv Env) (string, error) {
func LookPath2(file string, env map[string]string) (string, error) {
var exts []string
x := lenv.Getenv(`PATHEXT`)
x := getenv(env, `PATHEXT`)
if x != "" {
for e := range strings.SplitSeq(strings.ToLower(x), `;`) {
if e == "" {
@@ -85,7 +85,7 @@ func LookPath2(file string, lenv Env) (string, error) {
if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
return f, nil
}
path := lenv.Getenv("path")
path := getenv(env, "path")
for _, dir := range filepath.SplitList(path) {
if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil {
return f, nil

View File

@@ -1,138 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
import (
"fmt"
"io"
"strings"
"go.yaml.in/yaml/v4"
)
// ActionRunsUsing is the type of runner for the action
type ActionRunsUsing string
func (a *ActionRunsUsing) UnmarshalYAML(unmarshal func(any) error) error {
var using string
if err := unmarshal(&using); err != nil {
return err
}
// Force input to lowercase for case insensitive comparison
format := ActionRunsUsing(strings.ToLower(using))
switch format {
case ActionRunsUsingNode24, ActionRunsUsingNode20, ActionRunsUsingNode16, ActionRunsUsingNode12, ActionRunsUsingDocker, ActionRunsUsingComposite, ActionRunsUsingGo:
*a = format
default:
return fmt.Errorf("The runs.using key in action.yml must be one of: %v, got %s", []string{
ActionRunsUsingComposite,
ActionRunsUsingDocker,
ActionRunsUsingNode12,
ActionRunsUsingNode16,
ActionRunsUsingNode20,
ActionRunsUsingNode24,
ActionRunsUsingGo,
}, format)
}
return nil
}
const (
// ActionRunsUsingNode12 for running with node12
ActionRunsUsingNode12 = "node12"
// ActionRunsUsingNode16 for running with node16
ActionRunsUsingNode16 = "node16"
// ActionRunsUsingNode20 for running with node20
ActionRunsUsingNode20 = "node20"
// ActionRunsUsingNode24 for running with node24
ActionRunsUsingNode24 = "node24"
// ActionRunsUsingDocker for running with docker
ActionRunsUsingDocker = "docker"
// ActionRunsUsingComposite for running composite
ActionRunsUsingComposite = "composite"
// ActionRunsUsingGo for running with go
ActionRunsUsingGo = "go"
)
func (a ActionRunsUsing) IsNode() bool {
switch a {
case ActionRunsUsingNode12, ActionRunsUsingNode16, ActionRunsUsingNode20, ActionRunsUsingNode24:
return true
default:
return false
}
}
func (a ActionRunsUsing) IsDocker() bool {
return a == ActionRunsUsingDocker
}
func (a ActionRunsUsing) IsComposite() bool {
return a == ActionRunsUsingComposite
}
// ActionRuns are a field in Action
type ActionRuns struct {
Using ActionRunsUsing `yaml:"using"`
Env map[string]string `yaml:"env"`
Main string `yaml:"main"`
Pre string `yaml:"pre"`
PreIf string `yaml:"pre-if"`
Post string `yaml:"post"`
PostIf string `yaml:"post-if"`
Image string `yaml:"image"`
PreEntrypoint string `yaml:"pre-entrypoint"`
Entrypoint string `yaml:"entrypoint"`
PostEntrypoint string `yaml:"post-entrypoint"`
Args []string `yaml:"args"`
Steps []Step `yaml:"steps"`
}
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.
type Action struct {
Name string `yaml:"name"`
Author string `yaml:"author"`
Description string `yaml:"description"`
Inputs map[string]Input `yaml:"inputs"`
Outputs map[string]Output `yaml:"outputs"`
Runs ActionRuns `yaml:"runs"`
Branding struct {
Color string `yaml:"color"`
Icon string `yaml:"icon"`
} `yaml:"branding"`
}
// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.
type Input struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
}
// Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions. For example, if you had an action that performed the addition of two inputs (x + y = z), the action could output the sum (z) for other actions to use as an input.
type Output struct {
Description string `yaml:"description"`
Value string `yaml:"value"`
}
// ReadAction reads an action from a reader
func ReadAction(in io.Reader) (*Action, error) {
a := new(Action)
err := yaml.NewDecoder(in).Decode(a)
if err != nil {
return nil, err
}
// set defaults
if a.Runs.PreIf == "" {
a.Runs.PreIf = "always()"
}
if a.Runs.PostIf == "" {
a.Runs.PostIf = "always()"
}
return a, nil
}

View File

@@ -1,82 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
import (
"strings"
"testing"
)
func TestReadActionDefaultsAndCaseInsensitiveUsing(t *testing.T) {
action, err := ReadAction(strings.NewReader(`
name: example
runs:
using: NoDe24
main: dist/index.js
`))
if err != nil {
t.Fatal(err)
}
if action.Runs.Using != ActionRunsUsingNode24 {
t.Fatalf("using = %q, want %q", action.Runs.Using, ActionRunsUsingNode24)
}
if action.Runs.PreIf != "always()" {
t.Fatalf("pre-if = %q, want always()", action.Runs.PreIf)
}
if action.Runs.PostIf != "always()" {
t.Fatalf("post-if = %q, want always()", action.Runs.PostIf)
}
}
func TestReadActionPreservesExplicitConditions(t *testing.T) {
action, err := ReadAction(strings.NewReader(`
runs:
using: composite
pre-if: success()
post-if: failure()
steps:
- run: echo hello
`))
if err != nil {
t.Fatal(err)
}
if action.Runs.PreIf != "success()" || action.Runs.PostIf != "failure()" {
t.Fatalf("conditions = %q/%q, want explicit values", action.Runs.PreIf, action.Runs.PostIf)
}
if !action.Runs.Using.IsComposite() || action.Runs.Using.IsDocker() || action.Runs.Using.IsNode() {
t.Fatalf("unexpected using predicates for %q", action.Runs.Using)
}
}
func TestReadActionRejectsUnknownUsing(t *testing.T) {
_, err := ReadAction(strings.NewReader(`
runs:
using: node99
`))
if err == nil {
t.Fatal("expected unknown runs.using to fail")
}
if !strings.Contains(err.Error(), "node99") {
t.Fatalf("error = %q, want invalid value", err)
}
}
func TestReadActionDockerEntrypoints(t *testing.T) {
action, err := ReadAction(strings.NewReader(`
runs:
using: docker
image: Dockerfile
pre-entrypoint: pre.sh
post-entrypoint: post.sh
`))
if err != nil {
t.Fatal(err)
}
if action.Runs.PreEntrypoint != "pre.sh" {
t.Fatalf("pre-entrypoint = %q, want pre.sh", action.Runs.PreEntrypoint)
}
if action.Runs.PostEntrypoint != "post.sh" {
t.Fatalf("post-entrypoint = %q, want post.sh", action.Runs.PostEntrypoint)
}
}

View File

@@ -1,222 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2021 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
import (
"context"
"fmt"
"strings"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
)
type GithubContext struct {
Event map[string]any `json:"event"`
EventPath string `json:"event_path"`
Workflow string `json:"workflow"`
RunID string `json:"run_id"`
RunNumber string `json:"run_number"`
Actor string `json:"actor"`
Repository string `json:"repository"`
EventName string `json:"event_name"`
Sha string `json:"sha"`
Ref string `json:"ref"`
RefName string `json:"ref_name"`
RefType string `json:"ref_type"`
HeadRef string `json:"head_ref"`
BaseRef string `json:"base_ref"`
Token string `json:"token"`
Workspace string `json:"workspace"`
Action string `json:"action"`
ActionPath string `json:"action_path"`
ActionRef string `json:"action_ref"`
ActionRepository string `json:"action_repository"`
Job string `json:"job"`
JobName string `json:"job_name"`
RepositoryOwner string `json:"repository_owner"`
RetentionDays string `json:"retention_days"`
RunnerPerflog string `json:"runner_perflog"`
RunnerTrackingID string `json:"runner_tracking_id"`
ServerURL string `json:"server_url"`
APIURL string `json:"api_url"`
GraphQLURL string `json:"graphql_url"`
// For Gitea
RunAttempt string `json:"run_attempt"`
}
func asString(v any) string {
if v == nil {
return ""
} else if s, ok := v.(string); ok {
return s
}
return ""
}
func nestedMapLookup(m map[string]any, ks ...string) (rval any) {
var ok bool
if len(ks) == 0 { // degenerate input
return nil
}
if rval, ok = m[ks[0]]; !ok {
return nil
} else if len(ks) == 1 { // we've reached the final key
return rval
} else if m, ok = rval.(map[string]any); !ok {
return nil
} else { // 1+ more keys
return nestedMapLookup(m, ks[1:]...)
}
}
func withDefaultBranch(ctx context.Context, b string, event map[string]any) map[string]any {
repoI, ok := event["repository"]
if !ok {
repoI = make(map[string]any)
}
repo, ok := repoI.(map[string]any)
if !ok {
common.Logger(ctx).Warnf("unable to set default branch to %v", b)
return event
}
// if the branch is already there return with no changes
if _, ok = repo["default_branch"]; ok {
return event
}
repo["default_branch"] = b
event["repository"] = repo
return event
}
var (
findGitRef = git.FindGitRef
findGitRevision = git.FindGitRevision
)
func (ghc *GithubContext) SetRef(ctx context.Context, defaultBranch, repoPath string) {
logger := common.Logger(ctx)
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
switch ghc.EventName {
case "pull_request_target":
ghc.Ref = "refs/heads/" + ghc.BaseRef
case "pull_request", "pull_request_review", "pull_request_review_comment":
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
case "deployment", "deployment_status":
ghc.Ref = asString(nestedMapLookup(ghc.Event, "deployment", "ref"))
case "release":
ghc.Ref = "refs/tags/" + asString(nestedMapLookup(ghc.Event, "release", "tag_name"))
case "push", "create", "workflow_dispatch":
ghc.Ref = asString(ghc.Event["ref"])
default:
defaultBranch := asString(nestedMapLookup(ghc.Event, "repository", "default_branch"))
if defaultBranch != "" {
ghc.Ref = "refs/heads/" + defaultBranch
}
}
if ghc.Ref == "" {
ref, err := findGitRef(ctx, repoPath)
if err != nil {
logger.Warningf("unable to get git ref: %v", err)
} else {
logger.Debugf("using github ref: %s", ref)
ghc.Ref = ref
}
// set the branch in the event data
if defaultBranch != "" {
ghc.Event = withDefaultBranch(ctx, defaultBranch, ghc.Event)
} else {
ghc.Event = withDefaultBranch(ctx, "master", ghc.Event)
}
if ghc.Ref == "" {
ghc.Ref = "refs/heads/" + asString(nestedMapLookup(ghc.Event, "repository", "default_branch"))
}
}
}
func (ghc *GithubContext) SetSha(ctx context.Context, repoPath string) {
logger := common.Logger(ctx)
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
switch ghc.EventName {
case "pull_request_target":
ghc.Sha = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
case "deployment", "deployment_status":
ghc.Sha = asString(nestedMapLookup(ghc.Event, "deployment", "sha"))
case "push", "create", "workflow_dispatch":
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
ghc.Sha = asString(ghc.Event["after"])
}
}
if ghc.Sha == "" {
_, sha, err := findGitRevision(ctx, repoPath)
if err != nil {
logger.Warningf("unable to get git revision: %v", err)
} else {
ghc.Sha = sha
}
}
}
func (ghc *GithubContext) SetRepositoryAndOwner(ctx context.Context, githubInstance, remoteName, repoPath string) {
if ghc.Repository == "" {
repo, err := git.FindGithubRepo(ctx, repoPath, githubInstance, remoteName)
if err != nil {
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v; remoteName: %v, repoPath: %v): %v", githubInstance, remoteName, repoPath, err)
return
}
ghc.Repository = repo
}
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
}
func (ghc *GithubContext) SetRefTypeAndName() {
var refType, refName string
// https://docs.github.com/en/actions/learn-github-actions/environment-variables
if strings.HasPrefix(ghc.Ref, "refs/tags/") {
refType = "tag"
refName = ghc.Ref[len("refs/tags/"):]
} else if strings.HasPrefix(ghc.Ref, "refs/heads/") {
refType = "branch"
refName = ghc.Ref[len("refs/heads/"):]
} else if strings.HasPrefix(ghc.Ref, "refs/pull/") {
refType = ""
refName = ghc.Ref[len("refs/pull/"):]
}
if ghc.RefType == "" {
ghc.RefType = refType
}
if ghc.RefName == "" {
ghc.RefName = refName
}
}
func (ghc *GithubContext) SetBaseAndHeadRef() {
if ghc.EventName == "pull_request" || ghc.EventName == "pull_request_target" {
if ghc.BaseRef == "" {
ghc.BaseRef = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "ref"))
}
if ghc.HeadRef == "" {
ghc.HeadRef = asString(nestedMapLookup(ghc.Event, "pull_request", "head", "ref"))
}
}
}

View File

@@ -1,16 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// Copyright 2021 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
type JobContext struct {
Status string `json:"status"`
Container struct {
ID string `json:"id"`
Network string `json:"network"`
} `json:"container"`
Services map[string]struct {
ID string `json:"id"`
} `json:"services"`
}

View File

@@ -1,410 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
import (
"errors"
"fmt"
"io"
"io/fs"
"math"
"os"
"path/filepath"
"regexp"
"slices"
log "github.com/sirupsen/logrus"
)
// WorkflowPlanner contains methods for creating plans
type WorkflowPlanner interface {
PlanEvent(eventName string) (*Plan, error)
PlanJob(jobName string) (*Plan, error)
PlanAll() (*Plan, error)
GetEvents() []string
}
// Plan contains a list of stages to run in series
type Plan struct {
Stages []*Stage
}
// Stage contains a list of runs to execute in parallel
type Stage struct {
Runs []*Run
}
// Run represents a job from a workflow that needs to be run
type Run struct {
Workflow *Workflow
JobID string
}
func (r *Run) String() string {
jobName := r.Job().Name
if jobName == "" {
jobName = r.JobID
}
return jobName
}
// Job returns the job for this Run
func (r *Run) Job() *Job {
return r.Workflow.GetJob(r.JobID)
}
type WorkflowFiles struct {
workflowDirEntry os.DirEntry
dirPath string
}
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
path, err := filepath.Abs(path)
if err != nil {
return nil, err
}
fi, err := os.Stat(path)
if err != nil {
return nil, err
}
var workflows []WorkflowFiles
if fi.IsDir() {
log.Debugf("Loading workflows from '%s'", path)
if noWorkflowRecurse {
files, err := os.ReadDir(path)
if err != nil {
return nil, err
}
for _, v := range files {
workflows = append(workflows, WorkflowFiles{
dirPath: path,
workflowDirEntry: v,
})
}
} else {
log.Debug("Loading workflows recursively")
if err := filepath.Walk(path,
func(p string, f os.FileInfo, err error) error {
if err != nil {
return err
}
if !f.IsDir() {
log.Debugf("Found workflow '%s' in '%s'", f.Name(), p)
workflows = append(workflows, WorkflowFiles{
dirPath: filepath.Dir(p),
workflowDirEntry: fs.FileInfoToDirEntry(f),
})
}
return nil
}); err != nil {
return nil, err
}
}
} else {
log.Debugf("Loading workflow '%s'", path)
dirname := filepath.Dir(path)
workflows = append(workflows, WorkflowFiles{
dirPath: dirname,
workflowDirEntry: fs.FileInfoToDirEntry(fi),
})
}
wp := new(workflowPlanner)
for _, wf := range workflows {
ext := filepath.Ext(wf.workflowDirEntry.Name())
if ext == ".yml" || ext == ".yaml" {
f, err := os.Open(filepath.Join(wf.dirPath, wf.workflowDirEntry.Name()))
if err != nil {
return nil, err
}
log.Debugf("Reading workflow '%s'", f.Name())
workflow, err := ReadWorkflow(f)
if err != nil {
_ = f.Close()
if err == io.EOF {
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", wf.workflowDirEntry.Name(), err)
}
return nil, fmt.Errorf("workflow is not valid. '%s': %w", wf.workflowDirEntry.Name(), err)
}
_, err = f.Seek(0, 0)
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("error occurring when resetting io pointer in '%s': %w", wf.workflowDirEntry.Name(), err)
}
workflow.File = wf.workflowDirEntry.Name()
if workflow.Name == "" {
workflow.Name = wf.workflowDirEntry.Name()
}
err = validateJobName(workflow)
if err != nil {
_ = f.Close()
return nil, err
}
wp.workflows = append(wp.workflows, workflow)
_ = f.Close()
}
}
return wp, nil
}
// CombineWorkflowPlanner combines workflows to a WorkflowPlanner
func CombineWorkflowPlanner(workflows ...*Workflow) WorkflowPlanner {
return &workflowPlanner{
workflows: workflows,
}
}
func NewSingleWorkflowPlanner(name string, f io.Reader) (WorkflowPlanner, error) {
wp := new(workflowPlanner)
log.Debugf("Reading workflow %s", name)
workflow, err := ReadWorkflow(f)
if err != nil {
if err == io.EOF {
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", name, err)
}
return nil, fmt.Errorf("workflow is not valid. '%s': %w", name, err)
}
workflow.File = name
if workflow.Name == "" {
workflow.Name = name
}
err = validateJobName(workflow)
if err != nil {
return nil, err
}
wp.workflows = append(wp.workflows, workflow)
return wp, nil
}
func validateJobName(workflow *Workflow) error {
jobNameRegex := regexp.MustCompile(`^([[:alpha:]_][[:alnum:]_\-]*)$`)
for k := range workflow.Jobs {
if ok := jobNameRegex.MatchString(k); !ok {
return fmt.Errorf("workflow is not valid. '%s': Job name '%s' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", workflow.Name, k)
}
}
return nil
}
type workflowPlanner struct {
workflows []*Workflow
}
// PlanEvent builds a new list of runs to execute in parallel for an event name
func (wp *workflowPlanner) PlanEvent(eventName string) (*Plan, error) {
plan := new(Plan)
if len(wp.workflows) == 0 {
log.Debug("no workflows found by planner")
return plan, nil
}
var lastErr error
for _, w := range wp.workflows {
events := w.On()
if len(events) == 0 {
log.Debugf("no events found for workflow: %s", w.File)
continue
}
for _, e := range events {
if e == eventName {
stages, err := createStages(w, w.GetJobIDs()...)
if err != nil {
log.Warn(err)
lastErr = err
} else {
plan.mergeStages(stages)
}
}
}
}
return plan, lastErr
}
// PlanJob builds a new run to execute in parallel for a job name
func (wp *workflowPlanner) PlanJob(jobName string) (*Plan, error) {
plan := new(Plan)
if len(wp.workflows) == 0 {
log.Debugf("no jobs found for workflow: %s", jobName)
}
var lastErr error
for _, w := range wp.workflows {
stages, err := createStages(w, jobName)
if err != nil {
log.Warn(err)
lastErr = err
} else {
plan.mergeStages(stages)
}
}
return plan, lastErr
}
// PlanAll builds a new run to execute in parallel all
func (wp *workflowPlanner) PlanAll() (*Plan, error) {
plan := new(Plan)
if len(wp.workflows) == 0 {
log.Debug("no workflows found by planner")
return plan, nil
}
var lastErr error
for _, w := range wp.workflows {
stages, err := createStages(w, w.GetJobIDs()...)
if err != nil {
log.Warn(err)
lastErr = err
} else {
plan.mergeStages(stages)
}
}
return plan, lastErr
}
// GetEvents gets all the events in the workflows file
func (wp *workflowPlanner) GetEvents() []string {
events := make([]string, 0)
for _, w := range wp.workflows {
found := false
for _, e := range events {
if slices.Contains(w.On(), e) {
found = true
}
if found {
break
}
}
if !found {
events = append(events, w.On()...)
}
}
// sort the list based on depth of dependencies
slices.Sort(events)
return events
}
// MaxRunNameLen determines the max name length of all jobs
func (p *Plan) MaxRunNameLen() int {
maxRunNameLen := 0
for _, stage := range p.Stages {
for _, run := range stage.Runs {
runNameLen := len(run.String())
if runNameLen > maxRunNameLen {
maxRunNameLen = runNameLen
}
}
}
return maxRunNameLen
}
// GetJobIDs will get all the job names in the stage
func (s *Stage) GetJobIDs() []string {
names := make([]string, 0)
for _, r := range s.Runs {
names = append(names, r.JobID)
}
return names
}
// Merge stages with existing stages in plan
func (p *Plan) mergeStages(stages []*Stage) {
newStages := make([]*Stage, int(math.Max(float64(len(p.Stages)), float64(len(stages)))))
for i := range newStages {
newStages[i] = new(Stage)
if i >= len(p.Stages) {
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
} else if i >= len(stages) {
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
} else {
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
}
}
p.Stages = newStages
}
func createStages(w *Workflow, jobIDs ...string) ([]*Stage, error) {
// first, build a list of all the necessary jobs to run, and their dependencies
jobDependencies := make(map[string][]string)
for len(jobIDs) > 0 {
newJobIDs := make([]string, 0)
for _, jID := range jobIDs {
// make sure we haven't visited this job yet
if _, ok := jobDependencies[jID]; !ok {
if job := w.GetJob(jID); job != nil {
jobDependencies[jID] = job.Needs()
newJobIDs = append(newJobIDs, job.Needs()...)
}
}
}
jobIDs = newJobIDs
}
// next, build an execution graph
stages := make([]*Stage, 0)
for len(jobDependencies) > 0 {
stage := new(Stage)
for jID, jDeps := range jobDependencies {
// make sure all deps are in the graph already
if listInStages(jDeps, stages...) {
stage.Runs = append(stage.Runs, &Run{
Workflow: w,
JobID: jID,
})
delete(jobDependencies, jID)
}
}
if len(stage.Runs) == 0 {
return nil, fmt.Errorf("unable to build dependency graph for %s (%s)", w.Name, w.File)
}
stages = append(stages, stage)
}
if len(stages) == 0 {
return nil, errors.New("Could not find any stages to run. View the valid jobs with `act --list`. Use `act --help` to find how to filter by Job ID/Workflow/Event Name")
}
return stages, nil
}
// return true iff all strings in srcList exist in at least one of the stages
func listInStages(srcList []string, stages ...*Stage) bool {
for _, src := range srcList {
found := false
for _, stage := range stages {
for _, search := range stage.GetJobIDs() {
if src == search {
found = true
}
}
}
if !found {
return false
}
}
return true
}

View File

@@ -1,199 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2021 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
import (
"path/filepath"
"strings"
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type WorkflowPlanTest struct {
workflowPath string
errorMessage string
noWorkflowRecurse bool
}
func TestPlanner(t *testing.T) {
log.SetLevel(log.DebugLevel)
tables := []WorkflowPlanTest{
{"invalid-job-name/invalid-1.yml", "workflow is not valid. 'invalid-job-name-1': Job name 'invalid-JOB-Name-v1.2.3-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
{"invalid-job-name/invalid-2.yml", "workflow is not valid. 'invalid-job-name-2': Job name '1234invalid-JOB-Name-v123-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
{"invalid-job-name/valid-1.yml", "", false},
{"invalid-job-name/valid-2.yml", "", false},
{"empty-workflow", "unable to read workflow 'push.yml': file is empty: EOF", false},
{"nested", "unable to read workflow 'fail.yml': file is empty: EOF", false},
{"nested", "", true},
}
workdir, err := filepath.Abs("testdata")
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
for _, table := range tables {
fullWorkflowPath := filepath.Join(workdir, table.workflowPath)
_, err = NewWorkflowPlanner(fullWorkflowPath, table.noWorkflowRecurse)
if table.errorMessage == "" {
assert.NoError(t, err, "WorkflowPlanner should exit without any error")
} else {
assert.EqualError(t, err, table.errorMessage)
}
}
}
func TestWorkflow(t *testing.T) {
log.SetLevel(log.DebugLevel)
workflow := Workflow{
Jobs: map[string]*Job{
"valid_job": {
Name: "valid_job",
},
},
}
// Check that an invalid job id returns error
result, err := createStages(&workflow, "invalid_job_id")
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, result)
// Check that an valid job id returns non-error
result, err = createStages(&workflow, "valid_job")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, result)
}
func TestNewSingleWorkflowPlannerAndPlanMethods(t *testing.T) {
planner, err := NewSingleWorkflowPlanner("ci.yml", strings.NewReader(`
name: CI
on: [push, pull_request]
jobs:
build:
name: Build project
runs-on: ubuntu-latest
steps:
- run: make build
test:
needs: build
runs-on: ubuntu-latest
steps:
- run: make test
`))
require.NoError(t, err)
assert.Equal(t, []string{"pull_request", "push"}, planner.GetEvents())
eventPlan, err := planner.PlanEvent("push")
require.NoError(t, err)
require.Len(t, eventPlan.Stages, 2)
assert.Equal(t, []string{"build"}, eventPlan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, eventPlan.Stages[1].GetJobIDs())
assert.Equal(t, len("Build project"), eventPlan.MaxRunNameLen())
assert.Equal(t, "Build project", eventPlan.Stages[0].Runs[0].String())
assert.Equal(t, "build", eventPlan.Stages[0].Runs[0].JobID)
assert.NotNil(t, eventPlan.Stages[0].Runs[0].Job())
jobPlan, err := planner.PlanJob("test")
require.NoError(t, err)
require.Len(t, jobPlan.Stages, 2)
assert.Equal(t, []string{"build"}, jobPlan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, jobPlan.Stages[1].GetJobIDs())
allPlan, err := planner.PlanAll()
require.NoError(t, err)
require.Len(t, allPlan.Stages, 2)
assert.Equal(t, []string{"build"}, allPlan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, allPlan.Stages[1].GetJobIDs())
}
func TestCombineWorkflowPlannerMergesWorkflowStages(t *testing.T) {
first := mustReadWorkflow(t, `
name: First
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: make build
`)
second := mustReadWorkflow(t, `
name: Second
on: push
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: make lint
test:
needs: lint
runs-on: ubuntu-latest
steps:
- run: make test
`)
planner := CombineWorkflowPlanner(first, second)
plan, err := planner.PlanEvent("push")
require.NoError(t, err)
require.Len(t, plan.Stages, 2)
assert.ElementsMatch(t, []string{"build", "lint"}, plan.Stages[0].GetJobIDs())
assert.Equal(t, []string{"test"}, plan.Stages[1].GetJobIDs())
empty, err := planner.PlanEvent("schedule")
require.NoError(t, err)
assert.Empty(t, empty.Stages)
}
func TestPlannerErrorsForMissingAndCyclicJobs(t *testing.T) {
workflow := mustReadWorkflow(t, `
name: Cyclic
on: push
jobs:
a:
needs: b
runs-on: ubuntu-latest
steps:
- run: echo a
b:
needs: a
runs-on: ubuntu-latest
steps:
- run: echo b
`)
planner := CombineWorkflowPlanner(workflow)
plan, err := planner.PlanJob("missing")
require.Error(t, err)
assert.Empty(t, plan.Stages)
assert.Contains(t, err.Error(), "Could not find any stages")
plan, err = planner.PlanEvent("push")
require.Error(t, err)
assert.Empty(t, plan.Stages)
assert.Contains(t, err.Error(), "unable to build dependency graph")
}
func TestNewSingleWorkflowPlannerErrors(t *testing.T) {
_, err := NewSingleWorkflowPlanner("empty.yml", strings.NewReader(""))
require.Error(t, err)
assert.Contains(t, err.Error(), "file is empty")
_, err = NewSingleWorkflowPlanner("invalid.yml", strings.NewReader("jobs: ["))
require.Error(t, err)
assert.Contains(t, err.Error(), "workflow is not valid")
}
func mustReadWorkflow(t *testing.T, content string) *Workflow {
t.Helper()
workflow, err := ReadWorkflow(strings.NewReader(content))
require.NoError(t, err)
if workflow.Name == "" {
workflow.Name = "workflow"
}
return workflow
}

View File

@@ -1,49 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2021 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
import "fmt"
type stepStatus int
const (
StepStatusSuccess stepStatus = iota
StepStatusFailure
StepStatusSkipped
)
var stepStatusStrings = [...]string{
"success",
"failure",
"skipped",
}
func (s stepStatus) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
func (s *stepStatus) UnmarshalText(b []byte) error {
str := string(b)
for i, name := range stepStatusStrings {
if name == str {
*s = stepStatus(i)
return nil
}
}
return fmt.Errorf("invalid step status %q", str)
}
func (s stepStatus) String() string {
if int(s) >= len(stepStatusStrings) {
return ""
}
return stepStatusStrings[s]
}
type StepResult struct {
Outputs map[string]string `json:"outputs"`
Conclusion stepStatus `json:"conclusion"`
Outcome stepStatus `json:"outcome"`
}

View File

@@ -1,12 +0,0 @@
name: invalid-job-name-1
on: push
jobs:
invalid-JOB-Name-v1.2.3-docker_hub:
runs-on: ubuntu-latest
steps:
- run: echo hi
valid-JOB-Name-v123-docker_hub:
runs-on: ubuntu-latest
steps:
- run: echo hi

View File

@@ -1,8 +0,0 @@
name: invalid-job-name-2
on: push
jobs:
1234invalid-JOB-Name-v123-docker_hub:
runs-on: ubuntu-latest
steps:
- run: echo hi

View File

@@ -1,8 +0,0 @@
name: valid-job-name-1
on: push
jobs:
valid-JOB-Name-v123-docker_hub:
runs-on: ubuntu-latest
steps:
- run: echo hi

View File

@@ -1,8 +0,0 @@
name: valid-job-name-2
on: push
jobs:
___valid-JOB-Name-v123-docker_hub:
runs-on: ubuntu-latest
steps:
- run: echo hi

View File

@@ -1,9 +0,0 @@
name: Hello World Workflow
on: push
jobs:
hello-world:
name: Hello World Job
runs-on: ubuntu-latest
steps:
- run: echo "Hello World!"

View File

@@ -1,50 +0,0 @@
---
jobs:
strategy-all:
name: ${{ matrix.node-version }} | ${{ matrix.site }} | ${{ matrix.datacenter }}
runs-on: ubuntu-latest
steps:
- run: echo 'Hello!'
strategy:
fail-fast: false
matrix:
datacenter:
- site-c
- site-d
exclude:
- datacenter: site-d
node-version: 14.x
site: staging
include:
- php-version: 5.4
- datacenter: site-a
node-version: 10.x
site: prod
- datacenter: site-b
node-version: 12.x
site: dev
node-version: [14.x, 16.x]
site:
- staging
max-parallel: 2
strategy-no-matrix:
runs-on: ubuntu-latest
steps:
- run: echo 'Hello!'
strategy:
fail-fast: false
max-parallel: 2
strategy-only-fail-fast:
runs-on: ubuntu-latest
steps:
- run: echo 'Hello!'
strategy:
fail-fast: false
strategy-only-max-parallel:
runs-on: ubuntu-latest
steps:
- run: echo 'Hello!'
strategy:
max-parallel: 2
'on':
push: null

View File

@@ -1,910 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package model
import (
"crypto/sha256"
"fmt"
"io"
"maps"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"gitea.com/gitea/runner/act/common"
log "github.com/sirupsen/logrus"
"go.yaml.in/yaml/v4"
)
// Workflow is the structure of the files in .github/workflows
type Workflow struct {
File string
Name string `yaml:"name"`
RawOn yaml.Node `yaml:"on"`
Env map[string]string `yaml:"env"`
Jobs map[string]*Job `yaml:"jobs"`
Defaults Defaults `yaml:"defaults"`
RawConcurrency *RawConcurrency `yaml:"concurrency"`
RawPermissions yaml.Node `yaml:"permissions"`
}
// On events for the workflow
func (w *Workflow) On() []string {
switch w.RawOn.Kind {
case yaml.ScalarNode:
var val string
err := w.RawOn.Decode(&val)
if err != nil {
log.Fatal(err)
}
return []string{val}
case yaml.SequenceNode:
var val []string
err := w.RawOn.Decode(&val)
if err != nil {
log.Fatal(err)
}
return val
case yaml.MappingNode:
var val map[string]any
err := w.RawOn.Decode(&val)
if err != nil {
log.Fatal(err)
}
var keys []string
for k := range val {
keys = append(keys, k)
}
return keys
}
return nil
}
func (w *Workflow) OnEvent(event string) any {
if w.RawOn.Kind == yaml.MappingNode {
var val map[string]any
if !decodeNode(w.RawOn, &val) {
return nil
}
return val[event]
}
return nil
}
func (w *Workflow) OnSchedule() []string {
schedules := w.OnEvent("schedule")
if schedules == nil {
return []string{}
}
switch val := schedules.(type) {
case []any:
allSchedules := []string{}
for _, v := range val {
entry, ok := v.(map[string]any)
if !ok {
continue
}
if cron, ok := entry["cron"].(string); ok {
allSchedules = append(allSchedules, cron)
}
}
return allSchedules
default:
}
return []string{}
}
type WorkflowDispatchInput struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
Type string `yaml:"type"`
Options []string `yaml:"options"`
}
type WorkflowDispatch struct {
Inputs map[string]WorkflowDispatchInput `yaml:"inputs"`
}
func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch {
switch w.RawOn.Kind {
case yaml.ScalarNode:
var val string
if !decodeNode(w.RawOn, &val) {
return nil
}
if val == "workflow_dispatch" {
return &WorkflowDispatch{}
}
case yaml.SequenceNode:
var val []string
if !decodeNode(w.RawOn, &val) {
return nil
}
if slices.Contains(val, "workflow_dispatch") {
return &WorkflowDispatch{}
}
case yaml.MappingNode:
var val map[string]yaml.Node
if !decodeNode(w.RawOn, &val) {
return nil
}
n, found := val["workflow_dispatch"]
var workflowDispatch WorkflowDispatch
if found && decodeNode(n, &workflowDispatch) {
return &workflowDispatch
}
default:
return nil
}
return nil
}
type WorkflowCallInput struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
Type string `yaml:"type"`
}
type WorkflowCallOutput struct {
Description string `yaml:"description"`
Value string `yaml:"value"`
}
type WorkflowCall struct {
Inputs map[string]WorkflowCallInput `yaml:"inputs"`
Outputs map[string]WorkflowCallOutput `yaml:"outputs"`
}
type WorkflowCallResult struct {
Outputs map[string]string
}
func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
if w.RawOn.Kind != yaml.MappingNode {
// The callers expect for "on: workflow_call" and "on: [ workflow_call ]" a non nil return value
return &WorkflowCall{}
}
var val map[string]yaml.Node
if !decodeNode(w.RawOn, &val) {
return &WorkflowCall{}
}
var config WorkflowCall
node := val["workflow_call"]
if !decodeNode(node, &config) {
return &WorkflowCall{}
}
return &config
}
// Job is the structure of one job in a workflow
type Job struct {
Name string `yaml:"name"`
RawNeeds yaml.Node `yaml:"needs"`
RawRunsOn yaml.Node `yaml:"runs-on"`
Env yaml.Node `yaml:"env"`
If yaml.Node `yaml:"if"`
Steps []*Step `yaml:"steps"`
TimeoutMinutes string `yaml:"timeout-minutes"`
RawContinueOnError string `yaml:"continue-on-error"`
Services map[string]*ContainerSpec `yaml:"services"`
Strategy *Strategy `yaml:"strategy"`
RawContainer yaml.Node `yaml:"container"`
Defaults Defaults `yaml:"defaults"`
Outputs map[string]string `yaml:"outputs"`
Uses string `yaml:"uses"`
With map[string]any `yaml:"with"`
RawSecrets yaml.Node `yaml:"secrets"`
RawPermissions yaml.Node `yaml:"permissions"`
Result string
// Runtime fields set during execution (not from YAML):
ContinueOnError bool // true when all failing matrix combinations had continue-on-error=true
hasFirmFailure bool // true once any combination failed without continue-on-error
}
// SetContinueOnError records whether this combination's failure should not fail the workflow.
// Must be called under the job lock. Safe across parallel matrix combinations.
func (j *Job) SetContinueOnError(continueOnErr bool) {
if continueOnErr {
if !j.hasFirmFailure {
j.ContinueOnError = true
}
} else {
j.hasFirmFailure = true
j.ContinueOnError = false
}
}
// NeedsResult returns the job result as seen by dependent jobs through the
// `needs` context. A job that failed but was tolerated via continue-on-error
// reports "success" to its dependents, matching GitHub: such a failure must not
// block jobs gated on the default `if: success()`, even though the overall
// workflow run is still marked as failed.
func (j *Job) NeedsResult() string {
if j.Result == "failure" && j.ContinueOnError {
return "success"
}
return j.Result
}
// Strategy for the job
type Strategy struct {
FailFast bool
MaxParallel int
FailFastString string `yaml:"fail-fast"`
MaxParallelString string `yaml:"max-parallel"`
RawMatrix yaml.Node `yaml:"matrix"`
}
// Default settings that will apply to all steps in the job or workflow
type Defaults struct {
Run RunDefaults `yaml:"run"`
}
// Defaults for all run steps in the job or workflow
type RunDefaults struct {
Shell string `yaml:"shell"`
WorkingDirectory string `yaml:"working-directory"`
}
// GetMaxParallel sets default and returns value for `max-parallel`
func (s Strategy) GetMaxParallel() int {
// MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines`
// So I take the liberty to hardcode default limit to 4 and this is because:
// 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735
// 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45
// 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO
maxParallel := 4
if s.MaxParallelString != "" {
var err error
if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil {
log.Errorf("Failed to parse 'max-parallel' option: %v", err)
}
}
return maxParallel
}
// GetFailFast sets default and returns value for `fail-fast`
func (s Strategy) GetFailFast() bool {
// FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107
failFast := true
log.Debug(s.FailFastString)
if s.FailFastString != "" {
var err error
if failFast, err = strconv.ParseBool(s.FailFastString); err != nil {
log.Errorf("Failed to parse 'fail-fast' option: %v", err)
}
}
return failFast
}
func (j *Job) InheritSecrets() bool {
if j.RawSecrets.Kind != yaml.ScalarNode {
return false
}
var val string
if !decodeNode(j.RawSecrets, &val) {
return false
}
return val == "inherit"
}
func (j *Job) Secrets() map[string]string {
if j.RawSecrets.Kind != yaml.MappingNode {
return nil
}
var val map[string]string
if !decodeNode(j.RawSecrets, &val) {
return nil
}
return val
}
// Container details for the job
func (j *Job) Container() *ContainerSpec {
var val *ContainerSpec
switch j.RawContainer.Kind {
case yaml.ScalarNode:
val = new(ContainerSpec)
if !decodeNode(j.RawContainer, &val.Image) {
return nil
}
case yaml.MappingNode:
val = new(ContainerSpec)
if !decodeNode(j.RawContainer, val) {
return nil
}
}
return val
}
// Needs list for Job
func (j *Job) Needs() []string {
switch j.RawNeeds.Kind {
case yaml.ScalarNode:
var val string
if !decodeNode(j.RawNeeds, &val) {
return nil
}
return []string{val}
case yaml.SequenceNode:
var val []string
if !decodeNode(j.RawNeeds, &val) {
return nil
}
return val
}
return nil
}
// RunsOn list for Job
func (j *Job) RunsOn() []string {
return RunsOnFromNode(j.RawRunsOn)
}
// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a
// copy of the node (avoiding mutation of the shared Job) before reading the labels.
func RunsOnFromNode(rawRunsOn yaml.Node) []string {
switch rawRunsOn.Kind {
case yaml.MappingNode:
var val struct {
Group string
Labels yaml.Node
}
if !decodeNode(rawRunsOn, &val) {
return nil
}
labels := nodeAsStringSlice(val.Labels)
if val.Group != "" {
labels = append(labels, val.Group)
}
return labels
default:
return nodeAsStringSlice(rawRunsOn)
}
}
func nodeAsStringSlice(node yaml.Node) []string {
switch node.Kind {
case yaml.ScalarNode:
var val string
if !decodeNode(node, &val) {
return nil
}
return []string{val}
case yaml.SequenceNode:
var val []string
if !decodeNode(node, &val) {
return nil
}
return val
}
return nil
}
func environment(yml yaml.Node) map[string]string {
env := make(map[string]string)
if yml.Kind == yaml.MappingNode {
if !decodeNode(yml, &env) {
return nil
}
}
return env
}
// Environment returns string-based key=value map for a job
func (j *Job) Environment() map[string]string {
return environment(j.Env)
}
// normalizeMatrixValue converts a matrix value to []interface{}.
// Arrays pass through unchanged; scalars are wrapped in a single-element array.
// Unevaluated template expressions are wrapped as a fallback — proper resolution
// happens via EvaluateYamlNode before Matrix() is called. Nested maps are rejected.
func normalizeMatrixValue(key string, val any) ([]any, error) {
switch t := val.(type) {
case []any:
// Already an array - use as-is
return t, nil
case string, int, float64, bool, nil:
// Valid scalar types that can appear in YAML
// These can be unevaluated template expressions (strings) or literal values
return []any{t}, nil
case map[string]any:
// Nested map indicates misconfiguration - likely user error
return nil, fmt.Errorf("matrix key %q has invalid nested object value - expected scalar or array, got map", key)
default:
// Unknown types might indicate parsing issues
log.Warnf("matrix key %q has unexpected type %T, wrapping as single value", key, t)
return []any{t}, nil
}
}
// Matrix decodes the RawMatrix YAML node into a map[string][]interface{}.
// Scalar values are wrapped into single-element arrays automatically.
// Template expressions are resolved by EvaluateYamlNode before this method is
// called; if unresolved, the literal string is wrapped as a one-element fallback.
func (j *Job) Matrix() (map[string][]any, error) {
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
return map[string][]any{}, nil
}
// Decode to flexible map first so that scalar values don't cause a type error.
var flexVal map[string]any
err := j.Strategy.RawMatrix.Decode(&flexVal)
if err != nil {
// Fall back to the strict array-only format for backward compatibility.
var val map[string][]any
if !decodeNode(j.Strategy.RawMatrix, &val) {
return map[string][]any{}, nil
}
return val, nil
}
// Convert flexible format to expected format with validation
val := make(map[string][]any)
for k, v := range flexVal {
normalized, err := normalizeMatrixValue(k, v)
if err != nil {
return nil, err
}
val[k] = normalized
}
return val, nil
}
// GetMatrixes returns the matrix cross product
// It skips includes and hard fails excludes for non-existing keys
func (j *Job) GetMatrixes() ([]map[string]any, error) {
matrixes := make([]map[string]any, 0)
if j.Strategy != nil {
// Always set these values, even if there's an error later
j.Strategy.FailFast = j.Strategy.GetFailFast()
j.Strategy.MaxParallel = j.Strategy.GetMaxParallel()
m, err := j.Matrix()
if err != nil {
return nil, err
}
if len(m) > 0 {
includes := make([]map[string]any, 0)
extraIncludes := make([]map[string]any, 0)
addInclude := func(raw any) error {
include, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("the workflow is not valid. Matrix include %v is not a map of matrix keys to values", raw)
}
for k := range include {
if _, ok := m[k]; ok {
includes = append(includes, include)
return nil
}
}
extraIncludes = append(extraIncludes, include)
return nil
}
for _, v := range m["include"] {
switch t := v.(type) {
case []any:
for _, i := range t {
if err := addInclude(i); err != nil {
return nil, err
}
}
case any:
if err := addInclude(t); err != nil {
return nil, err
}
}
}
delete(m, "include")
excludes := make([]map[string]any, 0)
for _, e := range m["exclude"] {
exclude, ok := e.(map[string]any)
if !ok {
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude %v is not a map of matrix keys to values", e)
}
for k := range exclude {
if _, ok := m[k]; ok {
excludes = append(excludes, exclude)
} else {
// We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k)
}
}
}
delete(m, "exclude")
matrixProduct := common.CartesianProduct(m)
MATRIX:
for _, matrix := range matrixProduct {
for _, exclude := range excludes {
if commonKeysMatch(matrix, exclude) {
log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude)
continue MATRIX
}
}
matrixes = append(matrixes, matrix)
}
for _, include := range includes {
matched := false
for _, matrix := range matrixes {
if commonKeysMatch2(matrix, include, m) {
matched = true
log.Debugf("Adding include values '%v' to existing entry", include)
maps.Copy(matrix, include)
}
}
if !matched {
extraIncludes = append(extraIncludes, include)
}
}
for _, include := range extraIncludes {
log.Debugf("Adding include '%v'", include)
matrixes = append(matrixes, include)
}
if len(matrixes) == 0 {
matrixes = append(matrixes, make(map[string]any))
}
} else {
matrixes = append(matrixes, make(map[string]any))
}
} else {
matrixes = append(matrixes, make(map[string]any))
log.Debugf("Empty Strategy, matrixes=%v", matrixes)
}
return matrixes, nil
}
func commonKeysMatch(a, b map[string]any) bool {
for aKey, aVal := range a {
if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
return false
}
}
return true
}
func commonKeysMatch2(a, b map[string]any, m map[string][]any) bool {
for aKey, aVal := range a {
_, useKey := m[aKey]
if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {
return false
}
}
return true
}
// JobType describes what type of job we are about to run
type JobType int
const (
// JobTypeDefault is all jobs that have a `run` attribute
JobTypeDefault JobType = iota
// JobTypeReusableWorkflowLocal is all jobs that have a `uses` that is a local workflow in the .github/workflows directory
JobTypeReusableWorkflowLocal
// JobTypeReusableWorkflowRemote is all jobs that have a `uses` that references a workflow file in a github repo
JobTypeReusableWorkflowRemote
// JobTypeInvalid represents a job which is not configured correctly
JobTypeInvalid
)
func (j JobType) String() string {
switch j {
case JobTypeDefault:
return "default"
case JobTypeReusableWorkflowLocal:
return "local-reusable-workflow"
case JobTypeReusableWorkflowRemote:
return "remote-reusable-workflow"
}
return "unknown"
}
// Type returns the type of the job
func (j *Job) Type() (JobType, error) {
isReusable := j.Uses != ""
if isReusable {
isYaml, _ := regexp.MatchString(`\.(ya?ml)(?:$|@)`, j.Uses)
if isYaml {
isLocalPath := strings.HasPrefix(j.Uses, "./")
isRemotePath, _ := regexp.MatchString(`^[^.](.+?/){2,}.+\.ya?ml@`, j.Uses)
hasVersion, _ := regexp.MatchString(`\.ya?ml@`, j.Uses)
if isLocalPath {
return JobTypeReusableWorkflowLocal, nil
} else if isRemotePath && hasVersion {
return JobTypeReusableWorkflowRemote, nil
}
}
return JobTypeInvalid, fmt.Errorf("`uses` key references invalid workflow path '%s'. Must start with './' if it's a local workflow, or must start with '<org>/<repo>/' and include an '@' if it's a remote workflow", j.Uses)
}
return JobTypeDefault, nil
}
// ContainerSpec is the specification of the container to use for the job
type ContainerSpec struct {
Image string `yaml:"image"`
Env map[string]string `yaml:"env"`
Ports []string `yaml:"ports"`
Volumes []string `yaml:"volumes"`
Options string `yaml:"options"`
Credentials map[string]string `yaml:"credentials"`
Entrypoint string
Args string
Name string
Reuse bool
// Gitea specific
Cmd []string `yaml:"cmd"`
}
// Step is the structure of one step in a job
type Step struct {
Number int `yaml:"-"`
ID string `yaml:"id"`
If yaml.Node `yaml:"if"`
Name string `yaml:"name"`
Uses string `yaml:"uses"`
Run string `yaml:"run"`
WorkingDirectory string `yaml:"working-directory"`
Shell string `yaml:"shell"`
Env yaml.Node `yaml:"env"`
With map[string]string `yaml:"with"`
RawContinueOnError string `yaml:"continue-on-error"`
TimeoutMinutes string `yaml:"timeout-minutes"`
}
// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across
// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env
// yaml.Nodes in place, so each job must own its copy.
func (s *Step) Clone() *Step {
clone := *s
clone.If = CloneYamlNode(s.If)
clone.Env = CloneYamlNode(s.Env)
clone.With = maps.Clone(s.With)
return &clone
}
// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without
// mutating a node shared across parallel jobs.
func CloneYamlNode(n yaml.Node) yaml.Node {
clone := n
if n.Content != nil {
clone.Content = make([]*yaml.Node, len(n.Content))
for i, child := range n.Content {
if child != nil {
childClone := CloneYamlNode(*child)
clone.Content[i] = &childClone
}
}
}
return clone
}
// String gets the name of step
func (s *Step) String() string {
if s.Name != "" {
return s.Name
} else if s.Uses != "" {
return s.Uses
} else if s.Run != "" {
return s.Run
}
return s.ID
}
// Environment returns string-based key=value map for a step
func (s *Step) Environment() map[string]string {
return environment(s.Env)
}
// GetEnv gets the env for a step
func (s *Step) GetEnv() map[string]string {
env := s.Environment()
for k, v := range s.With {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(k), "_")
envKey = "INPUT_" + strings.ToUpper(envKey)
env[envKey] = v
}
return env
}
// ShellCommand returns the command for the shell
func (s *Step) ShellCommand() string {
var shellCommand string
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17
switch s.Shell {
case "", "bash":
shellCommand = "bash --noprofile --norc -e -o pipefail {0}"
case "pwsh":
shellCommand = "pwsh -command . '{0}'"
case "python":
shellCommand = "python {0}"
case "sh":
shellCommand = "sh -e {0}"
case "cmd":
shellCommand = "cmd /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\""
case "powershell":
shellCommand = "powershell -command . '{0}'"
default:
shellCommand = s.Shell
}
return shellCommand
}
// StepType describes what type of step we are about to run
type StepType int
const (
// StepTypeRun is all steps that have a `run` attribute
StepTypeRun StepType = iota
// StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...`
StepTypeUsesDockerURL
// StepTypeUsesActionLocal is all steps that have a `uses` that is a local action in a subdirectory
StepTypeUsesActionLocal
// StepTypeUsesActionRemote is all steps that have a `uses` that is a reference to a github repo
StepTypeUsesActionRemote
// StepTypeReusableWorkflowLocal is all steps that have a `uses` that is a local workflow in the .github/workflows directory
StepTypeReusableWorkflowLocal
// StepTypeReusableWorkflowRemote is all steps that have a `uses` that references a workflow file in a github repo
StepTypeReusableWorkflowRemote
// StepTypeInvalid is for steps that have invalid step action
StepTypeInvalid
)
func (s StepType) String() string {
switch s {
case StepTypeInvalid:
return "invalid"
case StepTypeRun:
return "run"
case StepTypeUsesActionLocal:
return "local-action"
case StepTypeUsesActionRemote:
return "remote-action"
case StepTypeUsesDockerURL:
return "docker"
case StepTypeReusableWorkflowLocal:
return "local-reusable-workflow"
case StepTypeReusableWorkflowRemote:
return "remote-reusable-workflow"
}
return "unknown"
}
// Type returns the type of the step
func (s *Step) Type() StepType {
if s.Run == "" && s.Uses == "" {
return StepTypeInvalid
}
if s.Run != "" {
if s.Uses != "" {
return StepTypeInvalid
}
return StepTypeRun
} else if strings.HasPrefix(s.Uses, "docker://") {
return StepTypeUsesDockerURL
} else if strings.HasPrefix(s.Uses, "./.github/workflows") && (strings.HasSuffix(s.Uses, ".yml") || strings.HasSuffix(s.Uses, ".yaml")) {
return StepTypeReusableWorkflowLocal
} else if !strings.HasPrefix(s.Uses, "./") && strings.Contains(s.Uses, ".github/workflows") && (strings.Contains(s.Uses, ".yml@") || strings.Contains(s.Uses, ".yaml@")) {
return StepTypeReusableWorkflowRemote
} else if strings.HasPrefix(s.Uses, "./") {
return StepTypeUsesActionLocal
}
return StepTypeUsesActionRemote
}
// UsesHash returns a hash of the uses string.
// For Gitea.
func (s *Step) UsesHash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Uses)))
}
// ReadWorkflow returns a list of jobs for a given workflow file reader
func ReadWorkflow(in io.Reader) (*Workflow, error) {
w := new(Workflow)
err := yaml.NewDecoder(in).Decode(w)
return w, err
}
// GetJob will get a job by name in the workflow
func (w *Workflow) GetJob(jobID string) *Job {
for id, j := range w.Jobs {
if jobID == id {
if j.Name == "" {
j.Name = id
}
if j.If.Value == "" {
j.If.Value = "success()"
}
return j
}
}
return nil
}
// GetJobIDs will get all the job names in the workflow
func (w *Workflow) GetJobIDs() []string {
ids := make([]string, 0)
for id := range w.Jobs {
ids = append(ids, id)
}
return ids
}
var OnDecodeNodeError = func(node yaml.Node, out any, err error) {
log.Fatalf("Failed to decode node %v into %T: %v", node, out, err)
}
func decodeNode(node yaml.Node, out any) bool {
if err := node.Decode(out); err != nil {
if OnDecodeNodeError != nil {
OnDecodeNodeError(node, out, err)
}
return false
}
return true
}
// For Gitea
// RawConcurrency represents a workflow concurrency or a job concurrency with uninterpolated options
type RawConcurrency struct {
Group string `yaml:"group,omitempty"`
CancelInProgress string `yaml:"cancel-in-progress,omitempty"`
RawExpression string `yaml:"-,omitempty"`
}
type objectConcurrency RawConcurrency
func (r *RawConcurrency) UnmarshalYAML(n *yaml.Node) error {
if err := n.Decode(&r.RawExpression); err == nil {
return nil
}
return n.Decode((*objectConcurrency)(r))
}
func (r *RawConcurrency) MarshalYAML() (any, error) {
if r.RawExpression != "" {
return r.RawExpression, nil
}
return (*objectConcurrency)(r), nil
}

File diff suppressed because it is too large Load Diff

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"
@@ -23,8 +25,8 @@ import (
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model"
"gitea.dev/actionslib/pkg/model"
"github.com/kballard/go-shellquote"
)
@@ -32,7 +34,7 @@ type actionStep interface {
step
getActionModel() *model.Action
getCompositeRunContext(context.Context) *RunContext
getCompositeRunContext(context.Context) (*RunContext, error)
getCompositeSteps() *compositeSteps
}
@@ -124,21 +126,9 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath
defer closer.Close()
action, err := model.ReadAction(reader)
// For Gitea, reduce log noise
// logger.Debugf("Read action %v from '%s'", action, "Unknown")
return action, err
}
// cachedActionTar returns the action's tree from the action cache, which only a remote action
// has an entry in.
func cachedActionTar(ctx context.Context, step actionStep, name, includePrefix string) (io.ReadCloser, error) {
remote, ok := step.(*stepActionRemote)
if !ok {
return nil, fmt.Errorf("action %q is a remote action but runs as %T", name, step)
}
return step.getRunContext().Config.ActionCache.GetTarArchive(ctx, remote.cacheDir, remote.resolvedSha, includePrefix)
}
func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error {
logger := common.Logger(ctx)
rc := step.getRunContext()
@@ -148,30 +138,25 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
return nil
}
var containerActionDirCopy string
containerActionDirCopy = strings.TrimSuffix(containerActionDir, actionPath)
containerActionDirCopy := strings.TrimSuffix(containerActionDir, actionPath)
logger.Debug(containerActionDirCopy)
if !strings.HasSuffix(containerActionDirCopy, `/`) {
containerActionDirCopy += `/`
}
if rc.Config != nil && rc.Config.ActionCache != nil {
ta, err := cachedActionTar(ctx, step, stepModel.Uses, "")
if err != nil {
return err
}
defer ta.Close()
return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta)
}
defer git.AcquireCloneLock(actionDir)()
if !rc.Config.NoActionPatch {
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
}
if err := removeGitIgnore(ctx, actionDir); err != nil {
return err
}
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx)
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx)
}
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
@@ -186,11 +171,10 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
}
action := step.getActionModel()
// For Gitea, reduce log noise
// logger.Debugf("About to run action %v", action)
err := setupActionEnv(ctx, step, remoteAction)
if err != nil {
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
populateEnvsFromSavedState(step.getEnv(), step, rc)
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
return err
}
@@ -205,12 +189,12 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err
}
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)}
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Main))
logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv())
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
location := actionLocation
if remoteAction == nil {
@@ -235,11 +219,11 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
return common.NewPipelineExecutor(
rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir),
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""),
)(ctx)
default:
return fmt.Errorf("The runs.using key must be one of: %v, got %s", []string{
return fmt.Errorf("the runs.using key must be one of: %v, got %s", []string{
model.ActionRunsUsingDocker,
model.ActionRunsUsingNode12,
model.ActionRunsUsingNode16,
@@ -252,18 +236,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
}
}
func setupActionEnv(ctx context.Context, step actionStep, _ *remoteAction) error {
rc := step.getRunContext()
// A few fields in the environment (e.g. GITHUB_ACTION_REPOSITORY)
// are dependent on the action. That means we can complete the
// setup only after resolving the whole action model and cloning
// the action
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
populateEnvsFromSavedState(step.getEnv(), step, rc)
populateEnvsFromInput(ctx, step.getEnv(), step.getActionModel(), rc)
return nil
// /var/run is a symlink, so without the flag node's import.meta.url differs from argv[1], which ESM actions compare.
func nodeActionCommand(script string) []string {
return []string{"node", "--preserve-symlinks-main", script}
}
// https://github.com/nektos/act/issues/228#issuecomment-629709055
@@ -313,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
@@ -327,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")
@@ -359,12 +353,6 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
return err
}
defer buildContext.Close()
} else if rc.Config.ActionCache != nil {
buildContext, err = cachedActionTar(ctx, step, actionName, contextDir)
if err != nil {
return err
}
defer buildContext.Close()
}
prepImage = ContainerNewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
ContextDir: contextDir,
@@ -386,34 +374,40 @@ 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.NewStepExpressionEvaluator(ctx, step)
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"]))
cmd, err := shellquote.Split(step.getStepModel().With["args"])
if err != nil {
return err
}
if len(cmd) == 0 {
cmd = action.Runs.Args
evalDockerArgs(ctx, step, action, &cmd)
}
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
ee, err := evalDockerEnv(ctx, step, action)
if err != nil {
return err
}
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
if action.Runs.Args != nil {
// a fresh slice, the manifest is evaluated again for every stage
cmd = make([]string, len(action.Runs.Args))
for i, v := range action.Runs.Args {
if cmd[i], err = ee.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate runs.args: %w", err)
}
}
}
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),
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
stepContainer.Remove(),
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
stepContainer.Start(true),
).Finally(
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
).Finally(stepContainer.Close())(ctx)
}
// 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
@@ -423,10 +417,12 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvalu
case stepStagePost:
entrypoint = runs.PostEntrypoint
default:
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
return fields, nil
}
entrypoint = runs.Entrypoint
if entrypoint == "" {
if fields := strings.Fields(step.getStepModel().With["entrypoint"]); len(fields) > 0 {
return fields, nil
}
}
}
if entrypoint == "" {
@@ -435,80 +431,70 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvalu
return shellquote.Split(entrypoint)
}
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
// 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)
eval := rc.NewExpressionEvaluator(ctx)
// Set Defaults
for k, input := range action.Inputs {
inputs[k] = eval.Interpolate(ctx, input.Default)
}
if stepModel.With != nil {
for k, v := range stepModel.With {
inputs[k] = eval.Interpolate(ctx, v)
if inputs[k], err = eval.Interpolate(ctx, input.Default); err != nil {
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
}
}
mergeIntoMap(step, step.getEnv(), inputs)
mergeIntoMap(step, step.getEnv(), inputs, step.getStepModel().With)
stepEE := rc.NewStepExpressionEvaluator(ctx, step)
for i, v := range *cmd {
(*cmd)[i] = stepEE.Interpolate(ctx, v)
}
mergeIntoMap(step, step.getEnv(), action.Runs.Env)
ee := rc.NewStepExpressionEvaluator(ctx, step)
for k, v := range *step.getEnv() {
(*step.getEnv())[k] = ee.Interpolate(ctx, v)
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
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) container.Container {
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container {
rc := step.getRunContext()
stepModel := step.getStepModel()
rawLogger := common.Logger(ctx).WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof("%s", s)
} else {
rawLogger.Debugf("%s", s)
}
return true
})
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 := rc.GetBindsAndMounts()
binds, mounts := rc.GetBindsAndMounts(ctx)
networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv(ctx) {
if rc.IsHostEnv() {
networkMode = "default"
}
stepContainer := ContainerNewContainer(&container.NewContainerInput{
Cmd: cmd,
Entrypoint: entrypoint,
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
Image: image,
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
Env: envList,
Mounts: mounts,
NetworkMode: networkMode,
Binds: binds,
Stdout: logWriter,
Stderr: logWriter,
Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
Options: rc.Config.ContainerOptions,
AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
return ContainerNewContainer(&container.NewContainerInput{
Cmd: cmd,
Entrypoint: entrypoint,
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
Image: image,
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.getStepModel().ID),
Env: envList,
Mounts: mounts,
NetworkMode: networkMode,
Binds: binds,
Stdout: logWriter,
Stderr: logWriter,
Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
RunnerOptions: runnerOptions,
AutoRemove: true,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
})
return stepContainer
}
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
@@ -521,15 +507,19 @@ func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *Run
}
}
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) {
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error {
eval := rc.NewExpressionEvaluator(ctx)
for inputID, input := range action.Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
envKey = "INPUT_" + envKey
if _, ok := (*env)[envKey]; !ok {
(*env)[envKey] = eval.Interpolate(ctx, input.Default)
var err error
if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil {
return fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
}
}
}
return nil
}
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
@@ -597,7 +587,7 @@ func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, conta
if sar, ok := step.(*stepActionRemote); ok {
actionDir = sar.actionDir()
actionPath = newRemoteAction(stepModel.Uses).Path
actionPath = sar.remoteAction.Path
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
}
@@ -629,31 +619,33 @@ func runPreStep(step actionStep) common.Executor {
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
x := action.Runs.Using
if !x.IsComposite() {
// defaults in pre steps were missing, however provided inputs are available
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
return err
}
}
switch {
case x.IsNode():
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err
}
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Pre)}
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Pre))
logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv())
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
return execDockerActionStage(ctx, step, stepStagePre)
case x.IsComposite():
if step.getCompositeSteps() == nil {
step.getCompositeRunContext(ctx)
if _, err := step.getCompositeRunContext(ctx); err != nil {
return err
}
}
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
@@ -662,9 +654,6 @@ func runPreStep(step actionStep) common.Executor {
return errors.New("missing steps in composite action")
case x == model.ActionRunsUsingGo:
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err
}
@@ -676,8 +665,8 @@ func runPreStep(step actionStep) common.Executor {
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
return common.NewPipelineExecutor(
rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir),
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""),
)(ctx)
default:
return nil
@@ -739,12 +728,12 @@ func runPostStep(step actionStep) common.Executor {
populateEnvsFromSavedState(step.getEnv(), step, rc)
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Post)}
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Post))
logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv())
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker():
populateEnvsFromSavedState(step.getEnv(), step, rc)
@@ -770,8 +759,8 @@ func runPostStep(step actionStep) common.Executor {
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
return common.NewPipelineExecutor(
rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir),
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""),
)(ctx)
default:

View File

@@ -1,156 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2023 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"archive/tar"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"io/fs"
"path"
"strings"
git "github.com/go-git/go-git/v5"
config "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
)
type ActionCache interface {
Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error)
GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error)
}
type GoGitActionCache struct {
Path string
}
func (c GoGitActionCache) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
gitPath := path.Join(c.Path, safeFilename(cacheDir)+".git")
gogitrepo, err := git.PlainInit(gitPath, true)
if errors.Is(err, git.ErrRepositoryAlreadyExists) {
gogitrepo, err = git.PlainOpen(gitPath)
}
if err != nil {
return "", err
}
tmpBranch := make([]byte, 12)
if _, err := rand.Read(tmpBranch); err != nil {
return "", err
}
branchName := hex.EncodeToString(tmpBranch)
var auth transport.AuthMethod
if token != "" {
auth = &http.BasicAuth{
Username: "token",
Password: token,
}
}
remote, err := gogitrepo.CreateRemoteAnonymous(&config.RemoteConfig{
Name: "anonymous",
URLs: []string{
url,
},
})
if err != nil {
return "", err
}
defer func() {
_ = gogitrepo.DeleteBranch(branchName)
}()
if err := remote.FetchContext(ctx, &git.FetchOptions{
RefSpecs: []config.RefSpec{
config.RefSpec(ref + ":" + branchName),
},
Auth: auth,
Force: true,
}); err != nil {
return "", err
}
hash, err := gogitrepo.ResolveRevision(plumbing.Revision(branchName))
if err != nil {
return "", err
}
return hash.String(), nil
}
func (c GoGitActionCache) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
gitPath := path.Join(c.Path, safeFilename(cacheDir)+".git")
gogitrepo, err := git.PlainOpen(gitPath)
if err != nil {
return nil, err
}
commit, err := gogitrepo.CommitObject(plumbing.NewHash(sha))
if err != nil {
return nil, err
}
files, err := commit.Files()
if err != nil {
return nil, err
}
rpipe, wpipe := io.Pipe()
// Interrupt io.Copy using ctx
ch := make(chan int, 1)
go func() {
select {
case <-ctx.Done():
wpipe.CloseWithError(ctx.Err())
case <-ch:
}
}()
go func() {
defer wpipe.Close()
defer close(ch)
tw := tar.NewWriter(wpipe)
cleanIncludePrefix := path.Clean(includePrefix)
wpipe.CloseWithError(files.ForEach(func(f *object.File) error {
if err := ctx.Err(); err != nil {
return err
}
name := f.Name
if strings.HasPrefix(name, cleanIncludePrefix+"/") {
name = name[len(cleanIncludePrefix)+1:]
} else if cleanIncludePrefix != "." && name != cleanIncludePrefix {
return nil
}
fmode, err := f.Mode.ToOSFileMode()
if err != nil {
return err
}
if fmode&fs.ModeSymlink == fs.ModeSymlink {
content, err := f.Contents()
if err != nil {
return err
}
return tw.WriteHeader(&tar.Header{
Name: name,
Mode: int64(fmode),
Linkname: content,
})
}
err = tw.WriteHeader(&tar.Header{
Name: name,
Mode: int64(fmode),
Size: f.Size,
})
if err != nil {
return err
}
reader, err := f.Reader()
if err != nil {
return err
}
_, err = io.Copy(tw, reader)
return err
}))
}()
return rpipe, err
}

Some files were not shown because too many files have changed in this diff Show More