Compare commits

..
13 Commits
Author SHA1 Message Date
silverwindandbircni 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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1217
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-08 18:45:10 +00:00
Zettat123andsilverwind 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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1215
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-09-08 04:16:02 +00:00
Renovate Botandsilverwind 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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1212
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-07 08:20:15 +00:00
silverwindandbircni 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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1204
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-05 08:52:10 +00:00
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1199
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
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 <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-02 20:32:41 +00:00
bircniandsilverwind 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 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1202
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
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 <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-08-29 02:00:21 +00:00
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1173
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Minjie Fang <[email protected]>
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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-27 09:36:27 +00:00
83 changed files with 3727 additions and 851 deletions

No files matched your search

+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+2 -2
View File
@@ -30,8 +30,8 @@ jobs:
# daemon retains them between runs, so this is usually a fast manifest re-check. # daemon retains them between runs, so this is usually a fast manifest re-check.
- name: pre-pull test images - name: pre-pull test images
env: env:
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 # renovate: datasource=docker TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker
run: | run: |
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
for attempt in 1 2 3; do for attempt in 1 2 3; do
+6 -16
View File
@@ -18,7 +18,7 @@ DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG) DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].1 # renovate: datasource=go GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].2 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8 GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
@@ -96,13 +96,8 @@ go-check:
fi fi
.PHONY: fmt-check .PHONY: fmt-check
fmt-check: fmt fmt-check: ## check the Go code formatting
@diff=$$(git diff --color=always -- '*.go'); \ $(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: deps-tools .PHONY: deps-tools
deps-tools: ## install tool dependencies deps-tools: ## install tool dependencies
@@ -149,13 +144,8 @@ tidy: ## run go mod tidy
fi fi
.PHONY: tidy-check .PHONY: tidy-check
tidy-check: tidy tidy-check: ## check that go.mod and go.sum are tidy
@diff=$$(git diff --color=always -- go.mod go.sum); \ $(GO) mod tidy -diff
if [ -n "$$diff" ]; then \
echo "Please run 'make tidy' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: test .PHONY: test
test: ## test everything (integration tests self-skip without docker/network) test: ## test everything (integration tests self-skip without docker/network)
@@ -171,7 +161,7 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless) test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
@./scripts/test-dind.sh $(TARGET) @./scripts/test-dind.sh $(TARGET)
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:934240a162082fd8b8a2f90cd5114446443f1eba1c5378f6687167ca405e6584 # renovate: datasource=docker E2E_JOB_IMAGE ?= node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2 # renovate: datasource=docker
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
E2E_CONCURRENCY ?= 8 E2E_CONCURRENCY ?= 8
+15 -2
View File
@@ -266,6 +266,17 @@ A job in a container reaches a service by its id on the job network, on the port
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. 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`)
A container a job starts through the Docker socket cannot bind-mount the workspace by the job's own path, the daemon does not have it. `GITEA_DOCKER_WORKSPACE` holds the path the daemon sees. Use it as the prefix of workspace binds, with `.` as the fallback for local use, here in a `docker-compose.yaml`:
```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. The proxy removes containers, networks and volumes after post steps and the completed hook. Named volumes created through it are job-scoped. To retain resources, mount the daemon socket explicitly in `container.options`. Host jobs use their existing Docker access, where `.` works.
#### Proxy #### Proxy
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`: Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
@@ -280,7 +291,7 @@ 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: 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` - `localhost`, `127.0.0.1` and `::1`
- the job's service containers - the job's service containers
- the Docker daemon, when it is reached over `tcp://` - the Docker daemon, when it is reached over `tcp://`
@@ -314,7 +325,9 @@ cache:
v2: false v2: false
``` ```
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 on its way into the job, undone whenever the action is downloaded again. 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, so it is made whatever `v2` says: that setting only governs the API the runner advertises. Set `runner.patch_actions: false` to leave every bundle exactly as shipped, an escape hatch for an action the edit breaks. The artifact actions then refuse again and the cache client keeps to v1. Those actions refuse any host they do not take for GitHub, so the runner edits that check out of the bundle on its way into the job and puts the shared copy back afterwards. A bundle it does not recognise is left alone. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork, so it is made whatever `v2` says. Set `runner.patch_actions: false` to leave bundles as shipped; the artifact actions then refuse and the cache client keeps to v1.
With v2 the job's artifact calls go via the cache server, so jobs need to reach it to upload artifacts, not just to cache. `v2: false` sends them to Gitea directly.
**Shared cache across multiple runners** **Shared cache across multiple runners**
+19 -5
View File
@@ -234,7 +234,7 @@ func StartHandler(opts Options) (*Handler, error) {
func (h *Handler) ExternalURL() string { func (h *Handler) ExternalURL() string {
// TODO: make the external url configurable if necessary // 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 { func (h *Handler) baseURL(cred JobCredential) string {
@@ -603,12 +603,12 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
h.logger.Debugf("%s %s", r.Method, r.URL.Path) h.logger.Debugf("%s %s", r.Method, r.URL.Path)
token := bearerToken(r) token := bearerToken(r)
if token == "" { if token == "" {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token")) h.unauthorized(w, r, errors.New("missing bearer token"))
return return
} }
cred, ok := h.lookupCredential(token) cred, ok := h.lookupCredential(token)
if !ok { if !ok {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token")) h.unauthorized(w, r, errors.New("unknown bearer token"))
return return
} }
ctx := context.WithValue(r.Context(), credKey{}, cred) ctx := context.WithValue(r.Context(), credKey{}, cred)
@@ -617,6 +617,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 // signedAuth authenticates a signed URL. purpose separates the flavours of URL the
// handler hands out, so one cannot be replayed as another; see computeSignature. // handler hands out, so one cannot be replayed as another; see computeSignature.
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle { func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
@@ -720,6 +728,12 @@ func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httpr
h.responseJSON(w, r, http.StatusOK) 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 { func bearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
const prefix = "Bearer " const prefix = "Bearer "
@@ -805,8 +819,8 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
} }
cache := &Cache{} cache := &Cache{}
err := db.FindOne(cache, err := db.FindOne(cache,
bolthold.Where("Repo").Eq(repo). bolthold.Where("Key").Eq(key).Index("Key").
And("Key").Eq(key). And("Repo").Eq(repo).
And("Version").Eq(version). And("Version").Eq(version).
And("Complete").Eq(complete). And("Complete").Eq(complete).
SortBy(sortBy).Reverse()) SortBy(sortBy).Reverse())
+4
View File
@@ -52,6 +52,10 @@ func signArtifactURL(h *Handler, id int64) string {
return h.signedArtifactURL(JobCredential{}, 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(t *testing.T) { func TestHandler(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache") dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(Options{Dir: dir}) handler, err := StartHandler(Options{Dir: dir})
+43 -12
View File
@@ -39,8 +39,8 @@ const (
blobUploadURLTTL = time.Hour blobUploadURLTTL = time.Hour
// twirpInternal is the only error code that is not the client's fault. twirpInternal = "internal"
twirpInternal = "internal" twirpUnauthenticated = "unauthenticated"
) )
func (h *Handler) registerV2Routes(router *httprouter.Router) { func (h *Handler) registerV2Routes(router *httprouter.Router) {
@@ -56,7 +56,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
cred := credFromContext(r.Context()) cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2CreateRequest](r) req, err := decodeTwirpRequest[v2CreateRequest](r)
if err != nil { if err != nil {
h.twirpError(w, r, "malformed_request", err) h.twirpError(w, r, "malformed", err)
return return
} }
if req.Key == "" || req.Version == "" { if req.Key == "" || req.Version == "" {
@@ -83,11 +83,30 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
return 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() now := time.Now().Unix()
cache := &Cache{ cache := &Cache{
Repo: cred.Repo, Repo: cred.Repo,
Key: req.Key, Key: req.Key,
Version: req.Version, Version: req.Version,
Owner: owner,
Size: -1, // the size is only known at finalize time Size: -1, // the size is only known at finalize time
CreatedAt: now, CreatedAt: now,
UsedAt: now, UsedAt: now,
@@ -107,7 +126,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
cred := credFromContext(r.Context()) cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2FinalizeRequest](r) req, err := decodeTwirpRequest[v2FinalizeRequest](r)
if err != nil { if err != nil {
h.twirpError(w, r, "malformed_request", err) h.twirpError(w, r, "malformed", err)
return return
} }
@@ -119,6 +138,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
defer db.Close() defer db.Close()
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false) 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 { if err != nil {
h.twirpError(w, r, twirpInternal, err) h.twirpError(w, r, twirpInternal, err)
return return
@@ -127,13 +153,15 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
h.twirpNotOK(w, r) h.twirpNotOK(w, r)
return return
} }
db.Close() // commitCache needs the store closed
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel)) if !cache.Complete {
if err := h.commitCache(cache); err != nil { db.Close() // commitCache needs the store closed
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err) cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
h.twirpNotOK(w, r) if err := h.commitCache(cache); err != nil {
return 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{ h.responseJSON(w, r, http.StatusOK, map[string]any{
@@ -147,7 +175,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
cred := credFromContext(r.Context()) cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2DownloadRequest](r) req, err := decodeTwirpRequest[v2DownloadRequest](r)
if err != nil { if err != nil {
h.twirpError(w, r, "malformed_request", err) h.twirpError(w, r, "malformed", err)
return return
} }
@@ -230,8 +258,11 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) { 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) h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
status := http.StatusBadRequest status := http.StatusBadRequest
if code == twirpInternal { switch code {
case twirpInternal:
status = http.StatusInternalServerError status = http.StatusInternalServerError
case twirpUnauthenticated:
status = http.StatusUnauthorized
} }
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()}) h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
} }
+110
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -141,6 +142,17 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
} }
list.WriteString(`</BlockList>`) 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()))
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{ finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"), "key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
@@ -241,3 +253,101 @@ func TestCacheServiceV2Lookups(t *testing.T) {
assert.Equal(t, false, got["ok"]) 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"])
}
+1
View File
@@ -16,6 +16,7 @@ type Cache struct {
Key string `json:"key" boltholdIndex:"Key"` Key string `json:"key" boltholdIndex:"Key"`
Version string `json:"version" boltholdIndex:"Version"` Version string `json:"version" boltholdIndex:"Version"`
Size int64 `json:"cacheSize"` 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"` Complete bool `json:"complete" boltholdIndex:"Complete"`
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"` UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"` CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
+47 -5
View File
@@ -5,6 +5,8 @@ package artifactcache
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@@ -23,15 +25,33 @@ const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactServic
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job // forwardOrNotFound is the router's fallback: the artifact service of the instance the job
// registered with, and the 404 the router would have written otherwise. // registered with, and the 404 the router would have written otherwise.
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) { func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
cred, ok := h.lookupCredential(bearerToken(r)) if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
http.NotFound(w, r) http.NotFound(w, r)
return 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, "/")) 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 { if err != nil {
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err) h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
w.WriteHeader(http.StatusBadGateway) 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 return
} }
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target) h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
@@ -44,9 +64,9 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
// directly and never through here. // directly and never through here.
r.Out.Host = target.Host r.Out.Host = target.Host
}, },
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
h.logger.Warnf("artifact service forward to %s: %v", target, err) h.logger.Warnf("artifact service forward to %s: %v", target, err)
w.WriteHeader(http.StatusBadGateway) h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
}, },
} }
if cred.InsecureTLS { if cred.InsecureTLS {
@@ -55,5 +75,27 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r) 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 base, err := url.Parse(cred.PublicURL); err == nil && base.Scheme != "" {
scheme = base.Scheme
}
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. // insecureTransport is shared, because a transport per request would pool no connections.
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
+93 -15
View File
@@ -4,6 +4,8 @@
package artifactcache package artifactcache
import ( import (
"cmp"
"encoding/json/v2"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -14,8 +16,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
// still point at Gitea, and nothing else is proxied.
func TestFrontResultsService(t *testing.T) { func TestFrontResultsService(t *testing.T) {
var gotHost, gotPath, gotProto string var gotHost, gotPath, gotProto string
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -23,34 +23,112 @@ func TestFrontResultsService(t *testing.T) {
_, _ = io.WriteString(w, `{"ok":true}`) _, _ = io.WriteString(w, `{"ok":true}`)
})) }))
defer gitea.Close() defer gitea.Close()
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
defer selfSigned.Close()
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"}) handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
require.NoError(t, err) require.NoError(t, err)
defer handler.Close() defer handler.Close()
const token = "forward-token" const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
client := &http.Client{Transport: &bearerTransport{token: token}} noRedirect := func(token string) *http.Client {
post := func(path string) int { 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) req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
require.NoError(t, err) require.NoError(t, err)
resp, err := client.Do(req) resp, err := noRedirect(token).Do(req)
require.NoError(t, err) require.NoError(t, err)
resp.Body.Close() defer resp.Body.Close()
return resp.StatusCode body := map[string]string{}
_ = json.UnmarshalRead(resp.Body, &body)
return resp.StatusCode, body
} }
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"), status, body := post(rpc)
"an unregistered token is forwarded nowhere") 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: gitea.URL})() defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL, PublicURL: "https://cache.example"})()
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact")) status, _ = post(rpc)
require.Equal(t, http.StatusOK, status, "a registered https public URL onto an http instance must proxy, not redirect")
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from") assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs") assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath) assert.Equal(t, rpc, gotPath)
gotPath = "" gotPath = ""
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do")) status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo")) 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") 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, PublicURL: "https://cache.example"}, want: http.StatusTemporaryRedirect,
},
{
name: "the job does not share this server's disregard for the certificate",
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example", InsecureTLS: true}, want: http.StatusOK,
},
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
{
name: "a runner too old to send its public URL leaves the terminator to say so",
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))
}
})
}
} }
+15 -1
View File
@@ -5,8 +5,10 @@
package artifactcache package artifactcache
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json/v2"
"fmt" "fmt"
"io" "io"
"net/http" "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 // 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. // never does, so no rename can collide with a block not yet moved.
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error { 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 { for i, blockID := range blockIDs {
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil { if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
return err return err
} }
} }
return nil return os.WriteFile(list, wanted, 0o600)
} }
func (s *Storage) Commit(id uint64, size int64) (int64, error) { func (s *Storage) Commit(id uint64, size int64) (int64, error) {
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -220,14 +221,14 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
downloads(router, artifactPath) downloads(router, artifactPath)
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf("%s:%s", addr, port), Addr: net.JoinHostPort(addr, port),
ReadHeaderTimeout: 2 * time.Second, ReadHeaderTimeout: 2 * time.Second,
Handler: router, Handler: router,
} }
// run server // run server
go func() { 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 { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal(err) logger.Fatal(err)
} }
+90 -37
View File
@@ -391,7 +391,11 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
} }
} }
if !isOfflineMode { // A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
_, _, present := refRevision(r, input.Ref)
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
if refresh {
err = r.FetchContext(ctx, &fetchOptions) err = r.FetchContext(ctx, &fetchOptions)
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return err return err
@@ -411,23 +415,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
} }
} }
// At this point we need to know if it's a tag or a branch rev, refType, _ := refRevision(r, input.Ref)
// 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 hash, err = r.ResolveRevision(rev); err != nil {
logger.Errorf("Unable to resolve %s: %v", input.Ref, err) logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
@@ -459,7 +447,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
reusedMsg := "" reusedMsg := ""
switch { switch {
case !isOfflineMode && !shallow: case refresh && !shallow:
// In shallow mode the depth-limited fetch above already advanced the ref. // In shallow mode the depth-limited fetch above already advanced the ref.
if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
logger.Debugf("Unable to pull %s: %v", refName, err) logger.Debugf("Unable to pull %s: %v", refName, err)
@@ -501,27 +489,37 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
} }
} }
// cloneAtDepth clones input.URL into input.Dir using opts. // cloneAtDepth falls back to a full clone when the shallow attempt fails.
// 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 cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
if input.Depth > 0 { if input.Depth > 0 {
for _, refName := range []plumbing.ReferenceName{ if plumbing.IsHash(input.Ref) {
plumbing.NewBranchReferenceName(input.Ref), r, err := fetchPinnedSHA(ctx, input, opts)
plumbing.NewTagReferenceName(input.Ref),
} {
shallowOpts := opts
shallowOpts.Depth = input.Depth
shallowOpts.SingleBranch = true
shallowOpts.ReferenceName = refName
shallowOpts.Tags = git.NoTags
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
if err == nil { if err == nil {
return r, nil return r, nil
} }
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err) logger.Debugf("Shallow fetch of %s at %s failed: %v", input.URL, input.Ref, err)
if rmErr := os.RemoveAll(input.Dir); rmErr != nil { if err := removePartialClone(input.Dir); err != nil {
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr) return nil, err
}
} else {
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
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 err := removePartialClone(input.Dir); err != nil {
return nil, err
}
} }
} }
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref) logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
@@ -530,14 +528,65 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
return git.PlainCloneContext(ctx, input.Dir, false, &opts) return git.PlainCloneContext(ctx, input.Dir, false, &opts)
} }
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 fetchPinnedSHA(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions) (*git.Repository, error) {
r, err := git.PlainInit(input.Dir, false)
if err != nil {
return nil, err
}
if _, err := r.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{input.URL}}); err != nil {
return nil, err
}
if err := r.FetchContext(ctx, &git.FetchOptions{
RefSpecs: []config.RefSpec{pinnedRefSpec(input.Ref)},
Depth: input.Depth,
Tags: git.NoTags,
Auth: opts.Auth,
Progress: opts.Progress,
InsecureSkipTLS: opts.InsecureSkipTLS,
}); err != nil {
return nil, err
}
return r, nil
}
// pinnedRefSpec keeps a hash-shaped name out of refs/heads and refs/tags.
func pinnedRefSpec(sha string) config.RefSpec {
return config.RefSpec(fmt.Sprintf("+%s:refs/pinned/%s", sha, sha))
}
// refRevision picks the revision to check out and reports whether it resolves locally.
func refRevision(r *git.Repository, ref string) (plumbing.Revision, string, bool) {
if plumbing.IsHash(ref) {
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
_, err := r.CommitObject(plumbing.NewHash(ref))
return plumbing.Revision(ref), "sha", err == nil
}
if _, err := r.Tag(ref); err == nil {
return plumbing.Revision(path.Join("refs", "tags", ref)), "tag", true
}
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
if _, err := r.Reference(remoteRef, false); err == nil {
return plumbing.Revision(remoteRef), "branch", true
}
rev := plumbing.Revision(ref)
_, err := r.ResolveRevision(rev)
return rev, "sha", err == nil
}
// isShallow reports whether the local repository was cloned with a limited depth. // isShallow reports whether the local repository was cloned with a limited depth.
func isShallow(r *git.Repository) bool { func isShallow(r *git.Repository) bool {
shallows, err := r.Storer.Shallow() shallows, err := r.Storer.Shallow()
return err == nil && len(shallows) > 0 return err == nil && len(shallows) > 0
} }
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history. // shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
// 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) { func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
tagRef := plumbing.NewTagReferenceName(ref) tagRef := plumbing.NewTagReferenceName(ref)
if _, err := r.Reference(tagRef, false); err == nil { if _, err := r.Reference(tagRef, false); err == nil {
@@ -548,5 +597,9 @@ func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
branchRef := plumbing.NewBranchReferenceName(ref) branchRef := plumbing.NewBranchReferenceName(ref)
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
} }
if plumbing.IsHash(ref) {
// The broad refspec carries only advertised tips, never a pinned commit.
return pinnedRefSpec(ref), true
}
return "", false return "", false
} }
+110 -6
View File
@@ -16,6 +16,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"syscall" "syscall"
"testing" "testing"
"time" "time"
@@ -24,6 +25,9 @@ import (
gogit "github.com/go-git/go-git/v5" gogit "github.com/go-git/go-git/v5"
gogitconfig "github.com/go-git/go-git/v5/config" gogitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/transport"
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test" logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -435,7 +439,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m)) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
} }
require.NoError(t, gitCmd("-C", workDir, "tag", "v1")) 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", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
@@ -461,14 +465,43 @@ func TestGitCloneExecutorShallow(t *testing.T) {
assert.Equal(t, "c3", gitHeadSubject(t, dir)) 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 falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ 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())) })(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))
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone") assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD")) })
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()
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 branch updates while staying shallow", func(t *testing.T) { t.Run("moving branch updates while staying shallow", func(t *testing.T) {
@@ -491,6 +524,77 @@ func TestGitCloneExecutorShallow(t *testing.T) {
}) })
} }
func TestGitCloneExecutorColdCloneSkipsRefresh(t *testing.T) {
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", "c1"))
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
for name, tt := range map[string]struct {
Ref string
Depth int
}{
"shallow branch": {"main", 1},
"full clone tag": {"v1", 0},
} {
t.Run(name, func(t *testing.T) {
counter := installCountingTransport(t)
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: tt.Ref, Dir: t.TempDir(), Depth: tt.Depth,
})(t.Context()))
assert.Equal(t, int64(1), counter.sessions.Load())
})
}
}
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
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"))
})
}
}
type countingTransport struct {
transport.Transport
sessions atomic.Int64
}
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
c.sessions.Add(1)
return c.Transport.NewUploadPackSession(ep, auth)
}
func installCountingTransport(t *testing.T) *countingTransport {
t.Helper()
counter := &countingTransport{Transport: gogitfile.DefaultClient}
gogitclient.InstallProtocol("file", counter)
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
return counter
}
func gitRevParse(t *testing.T, dir, rev string) string { func gitRevParse(t *testing.T, dir, rev string) string {
t.Helper() t.Helper()
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output() out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
+16 -1
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"sync"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -75,6 +76,19 @@ const (
// fragment, missingContainerError composes it into the message every operation shares. // fragment, missingContainerError composes it into the message every operation shares.
var ErrContainerNotFound = errors.New("does not exist") var ErrContainerNotFound = errors.New("does not exist")
// 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
}
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. // Info is a snapshot of a container, as of one inspect.
type Info struct { type Info struct {
ID string ID string
@@ -84,13 +98,14 @@ type Info struct {
// HealthOutput is the last healthcheck probe's output. // HealthOutput is the last healthcheck probe's output.
HealthOutput string HealthOutput string
Ports map[string]string // container port ("5432") to the host port it is published on 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
} }
// Container for managing docker run containers // Container for managing docker run containers
type Container interface { type Container interface {
Create(capAdd, capDrop []string) common.Executor Create(capAdd, capDrop []string) common.Executor
Copy(destPath string, files ...*FileEntry) common.Executor Copy(destPath string, files ...*FileEntry) common.Executor
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) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
Inspect(ctx context.Context) (*Info, error) Inspect(ctx context.Context) (*Info, error)
DumpLogs(ctx context.Context) error DumpLogs(ctx context.Context) error
+395
View File
@@ -0,0 +1,395 @@
// 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/filepath"
"regexp"
"runtime"
"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, err := filepath.Abs(os.TempDir())
if err != nil {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil
}
seen, err := daemonSeesDir(probeCtx, cli, dir)
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 filesystem, 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 proxy
}
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir,
// which is what a job's proxy socket mounted from there needs.
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir 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
}
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: marker.Name(), Target: "/gitea-runner-probe", ReadOnly: true},
}},
})
if cerrdefs.IsInvalidArgument(err) {
return false, nil
}
if err != nil {
return false, err
}
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
defer cancel()
if _, err := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); err != nil {
return false, fmt.Errorf("removing the docker proxy probe container failed: %w", err)
}
return true, nil
}
// 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(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,
}
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)
if err := addLabel(r, job); 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)
}()
return &DockerProxy{Socket: socket, 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))
}}, nil
}
func addLabel(r *http.Request, job 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)
}
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
}
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{}); 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...)
}
+469
View File
@@ -0,0 +1,469 @@
// 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/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()) })
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"}}`,
},
{
"/networks/create", `{"Name":"n"}`,
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/volumes/create", `{"Name":"v","labels":null}`,
`{"Name":"v","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{}, listFailure).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.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)
require.NoError(t, err)
t.Logf("daemon sees the runner's filesystem: %v", 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)
}
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 TestDaemonSeesDir(t *testing.T) {
dir := t.TempDir()
markers := make(map[string]bool)
for _, testCase := range []struct {
name string
private bool
removeErr error
}{
{name: "cleanup after cancellation"},
{name: "private filesystem with stale marker", private: true},
{name: "cleanup error after cancellation", removeErr: errors.New("cleanup failed")},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
daemonDir := dir
if testCase.private {
daemonDir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(daemonDir, "probe"), []byte("stale"), 0o600))
}
removed := false
cli := &probeClient{
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
require.Len(t, opts.HostConfig.Mounts, 1)
marker := opts.HostConfig.Mounts[0].Source
assert.Equal(t, mount.Mount{Type: mount.TypeBind, Source: marker, Target: "/gitea-runner-probe", ReadOnly: true}, opts.HostConfig.Mounts[0])
assert.False(t, markers[marker])
markers[marker] = true
info, err := os.Stat(marker)
require.NoError(t, err)
assert.True(t, info.Mode().IsRegular())
cancel()
if _, err := os.Stat(filepath.Join(daemonDir, filepath.Base(marker))); errors.Is(err, os.ErrNotExist) {
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
}
return mobyclient.ContainerCreateResult{ID: "probe"}, 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)
require.ErrorIs(t, err, testCase.removeErr)
assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen)
assert.Equal(t, !testCase.private, removed)
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(t, entries)
})
}
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
package container
import (
"errors"
"os"
"path/filepath"
"syscall"
)
func copyDockerSocketPermissions(socket string, info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("docker socket ownership is unavailable")
}
if err := os.Chown(socket, int(stat.Uid), int(stat.Gid)); err != nil {
return err
}
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
return err
}
return os.Chmod(socket, info.Mode().Perm())
}
+15
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")
}
+36 -31
View File
@@ -138,12 +138,12 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
).IfNot(common.Dryrun) ).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( return common.NewPipelineExecutor(
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath), common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
cr.connect(), cr.connect(),
cr.find(), cr.find(),
cr.copyDir(destPath, srcPath, useGitIgnore), cr.copyDir(destPath, srcPath, useGitIgnore, skipGitDir),
func(ctx context.Context) error { func(ctx context.Context) error {
// If this fails, then folders have wrong permissions on non root container // If this fails, then folders have wrong permissions on non root container
if cr.UID != 0 || cr.GID != 0 { if cr.UID != 0 || cr.GID != 0 {
@@ -252,6 +252,10 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
ID: inspect.ID, ID: inspect.ID,
Health: HealthNone, Health: HealthNone,
Ports: map[string]string{}, // an empty map, never null, in the expression context 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 state := inspect.State; state != nil { if state := inspect.State; state != nil {
@@ -484,14 +488,11 @@ func (cr *containerReference) remove() common.Executor {
RemoveVolumes: true, RemoveVolumes: true,
Force: true, Force: true,
}) })
switch { if cerrdefs.IsConflict(err) {
case cerrdefs.IsConflict(err): err = cr.waitForRemoval(ctx, idOrName)
// the daemon's own AutoRemove teardown is running, and it releases the volume }
// references and the network endpoint only once it finishes if err != nil && !cerrdefs.IsNotFound(err) {
cr.waitForRemoval(ctx, idOrName) return fmt.Errorf("failed to remove container %s: %w", idOrName, err)
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()
} }
logger.Debugf("Removed container: %v", idOrName) logger.Debugf("Removed container: %v", idOrName)
@@ -500,7 +501,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 // 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 // cleanup, so a job with several services can spend most of that budget here
ctx, cancel := context.WithTimeout(ctx, 15*time.Second) ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
@@ -510,18 +511,13 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
Condition: container.WaitConditionRemoved, Condition: container.WaitConditionRemoved,
}) })
select { select {
case <-waitResult.Result: case result := <-waitResult.Result:
case <-waitResult.Error: if result.Error != nil {
case <-ctx.Done(): return errors.New(result.Error.Message)
// 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 return nil
go func() { case err := <-waitResult.Error:
select { return err
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)
} }
} }
@@ -583,6 +579,14 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", 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, forcing --privileged off is not enough, other options reach the host too // For Gitea, forcing --privileged off is not enough, other options reach the host too
if !hostConfig.Privileged { if !hostConfig.Privileged {
@@ -932,7 +936,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
} }
} }
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 { return func(ctx context.Context) error {
if cr.id == "" { if cr.id == "" {
return cr.missingContainerError("copy directory to %s", dstPath) return cr.missingContainerError("copy directory to %s", dstPath)
@@ -973,9 +977,10 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
} }
fc := &filecollector.FileCollector{ fc := &filecollector.FileCollector{
Ignorer: ignorer, Ignorer: ignorer,
SrcPath: srcPath, SrcPath: srcPath,
SrcPrefix: srcPrefix, SrcPrefix: srcPrefix,
SkipGitDir: skipGitDir,
Handler: &filecollector.TarCollector{ Handler: &filecollector.TarCollector{
TarWriter: tw, TarWriter: tw,
UID: cr.UID, UID: cr.UID,
@@ -1264,15 +1269,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
type validVolumeMatcher struct { type validVolumeMatcher struct {
allowAll bool allowAll bool
named []glob.Glob named []*glob.Pattern
host []glob.Glob host []*glob.Pattern
} }
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher { func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
logger := common.Logger(ctx) logger := common.Logger(ctx)
ret := validVolumeMatcher{ ret := validVolumeMatcher{
named: make([]glob.Glob, 0, len(validVolumes)), named: make([]*glob.Pattern, 0, len(validVolumes)),
host: make([]glob.Glob, 0, len(validVolumes)), host: make([]*glob.Pattern, 0, len(validVolumes)),
} }
for _, v := range validVolumes { for _, v := range validVolumes {
+45 -13
View File
@@ -345,18 +345,22 @@ func TestDockerWaitFailure(t *testing.T) {
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not // A remove that raced the daemon's AutoRemove teardown is not a failure and must not
// be logged as one. // be logged as one.
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) { func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
removeFailure := errors.New("driver failed to remove root filesystem")
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true} removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"} killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
for _, tc := range []struct { for _, tc := range []struct {
name string name string
err error err error
wantWait bool wantWait bool
wantFailure bool waitErr error
wantErr error
}{ }{
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true}, {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: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
{name: "removed cleanly", err: nil}, {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) { t.Run(tc.name, func(t *testing.T) {
logger, hook := test.NewNullLogger() logger, hook := test.NewNullLogger()
@@ -366,21 +370,24 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err) client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
if tc.wantWait { if tc.wantWait {
removed := make(chan container.WaitResponse, 1) 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}). 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} cr := &containerReference{id: "abc", cli: client}
require.NoError(t, cr.remove()(ctx)) require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
// a failure keeps the id, so a later Remove() can retry it if tc.wantErr != nil {
if tc.wantFailure {
assert.Equal(t, "abc", cr.id) assert.Equal(t, "abc", cr.id)
assert.Len(t, hook.AllEntries(), 1)
} else { } else {
assert.Empty(t, cr.id) assert.Empty(t, cr.id)
assert.Empty(t, hook.AllEntries())
} }
assert.Empty(t, hook.AllEntries())
client.AssertExpectations(t) client.AssertExpectations(t)
}) })
} }
@@ -471,7 +478,7 @@ func TestRejectsMissingContainer(t *testing.T) {
assert.Contains(t, err.Error(), `container "job-1" does not exist`, 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("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx)) check("copyDir", cr.copyDir("/var/run/act", "/src", false, false)(ctx))
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx)) check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x") _, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
check("GetContainerArchive", err) check("GetContainerArchive", err)
@@ -801,6 +808,20 @@ func TestContainerInfoFromInspect(t *testing.T) {
assert.Equal(t, "abc123", info.ID) assert.Equal(t, "abc123", info.ID)
assert.Equal(t, HealthNone, info.Health) 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"},
},
})
assert.Equal(t, map[string]string{
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
"/var/run/docker.sock": "/var/run/docker.sock",
}, info.Mounts)
})
} }
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) { func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
@@ -833,6 +854,17 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
assert.Equal(t, 1, warnings("--network host", "")) assert.Equal(t, 1, warnings("--network host", ""))
} }
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 // A dead daemon must fail the job, not panic through logrus and not silently
// drop the requested platform. // drop the requested platform.
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) { func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
+12
View File
@@ -29,6 +29,18 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
return false, errors.New("Unsupported Operation") 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 // NewDockerBuildExecutor function to create a run executor for the container
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor { func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
+11 -5
View File
@@ -92,7 +92,7 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
} }
} }
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 { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
srcPrefix := filepath.Dir(srcPath) srcPrefix := filepath.Dir(srcPath)
@@ -110,9 +110,10 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
ignorer = gitignore.NewMatcher(ps) ignorer = gitignore.NewMatcher(ps)
} }
fc := &filecollector.FileCollector{ fc := &filecollector.FileCollector{
Ignorer: ignorer, Ignorer: ignorer,
SrcPath: srcPath, SrcPath: srcPath,
SrcPrefix: srcPrefix, SrcPrefix: srcPrefix,
SkipGitDir: skipGitDir,
Handler: &filecollector.CopyCollector{ Handler: &filecollector.CopyCollector{
DstDir: destPath, DstDir: destPath,
}, },
@@ -629,8 +630,11 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string {
func goArchToActionArch(arch string) string { func goArchToActionArch(arch string) string {
archMapper := map[string]string{ archMapper := map[string]string{
"x86_64": "X64", "x86_64": "X64",
"amd64": "X64",
"386": "X86", "386": "X86",
"arm": "ARM",
"aarch64": "ARM64", "aarch64": "ARM64",
"arm64": "ARM64",
} }
if arch, ok := archMapper[arch]; ok { if arch, ok := archMapper[arch]; ok {
return arch return arch
@@ -640,7 +644,9 @@ func goArchToActionArch(arch string) string {
func goOsToActionOs(os string) string { func goOsToActionOs(os string) string {
osMapper := map[string]string{ osMapper := map[string]string{
"darwin": "macOS", "linux": "Linux",
"darwin": "macOS",
"windows": "Windows",
} }
if os, ok := osMapper[os]; ok { if os, ok := osMapper[os]; ok {
return os return os
+28 -1
View File
@@ -27,6 +27,11 @@ import (
// Type assert HostEnvironment implements ExecutionsEnvironment // Type assert HostEnvironment implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &HostEnvironment{} 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) { func TestCopyDir(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
ctx := context.Background() ctx := context.Background()
@@ -42,10 +47,32 @@ func TestCopyDir(t *testing.T) {
_ = os.MkdirAll(e.TmpDir, 0o700) _ = os.MkdirAll(e.TmpDir, 0o700)
_ = os.MkdirAll(e.ToolCache, 0o700) _ = os.MkdirAll(e.ToolCache, 0o700)
_ = os.MkdirAll(e.ActPath, 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) 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) { func TestGetContainerArchive(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
ctx := context.Background() ctx := context.Background()
+3 -6
View File
@@ -50,7 +50,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv): case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:] localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
case multiLineEnv != -1: case multiLineEnv != -1:
multiLineEnvContent := "" var multiLineEnvContent []string
multiLineEnvDelimiter := line[multiLineEnv+2:] multiLineEnvDelimiter := line[multiLineEnv+2:]
delimiterFound := false delimiterFound := false
for s.Scan() { for s.Scan() {
@@ -59,10 +59,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
delimiterFound = true delimiterFound = true
break break
} }
if multiLineEnvContent != "" { multiLineEnvContent = append(multiLineEnvContent, content)
multiLineEnvContent += "\n"
}
multiLineEnvContent += content
} }
if err := s.Err(); err != nil { if err := s.Err(); err != nil {
return fmt.Errorf("reading env file: %w", err) return fmt.Errorf("reading env file: %w", err)
@@ -70,7 +67,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
if !delimiterFound { if !delimiterFound {
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter) return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
} }
localEnv[line[:multiLineEnv]] = multiLineEnvContent localEnv[line[:multiLineEnv]] = strings.Join(multiLineEnvContent, "\n")
default: default:
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line) return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
} }
+5
View File
@@ -86,6 +86,11 @@ func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
env := map[string]string{} env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background())) require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "line1\n\nline2", env["FOO"]) 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) { func TestParseEnvFileUTF8BOM(t *testing.T) {
+11 -4
View File
@@ -94,10 +94,11 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
} }
type FileCollector struct { type FileCollector struct {
Ignorer gitignore.Matcher Ignorer gitignore.Matcher
SrcPath string SrcPath string
SrcPrefix string SrcPrefix string
Handler Handler SkipGitDir bool
Handler Handler
} }
func openGitIndex(path string) (*index.Index, error) { func openGitIndex(path string) (*index.Index, error) {
@@ -124,6 +125,12 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." { if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
return nil return nil
} }
if fc.SkipGitDir && split[len(split)-1] == ".git" {
if fi.IsDir() {
return filepath.SkipDir
}
return nil
}
var entry *index.Entry var entry *index.Entry
if i != nil { if i != nil {
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/")) entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
+82 -42
View File
@@ -32,7 +32,7 @@ type actionStep interface {
step step
getActionModel() *model.Action getActionModel() *model.Action
getCompositeRunContext(context.Context) *RunContext getCompositeRunContext(context.Context) (*RunContext, error)
getCompositeSteps() *compositeSteps getCompositeSteps() *compositeSteps
} }
@@ -147,14 +147,14 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
if !rc.Config.NoActionPatch { if !rc.Config.NoActionPatch {
// A concurrent job's prepare resets this directory, so patch under the copy's lock. // A concurrent job's prepare resets this directory, so patch under the copy's lock.
patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel())) defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
} }
if err := removeGitIgnore(ctx, actionDir); err != nil { if err := removeGitIgnore(ctx, actionDir); err != nil {
return err 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 { func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
@@ -172,7 +172,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv()) rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
populateEnvsFromSavedState(step.getEnv(), step, rc) populateEnvsFromSavedState(step.getEnv(), step, rc)
populateEnvsFromInput(ctx, step.getEnv(), action, rc) if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
return err
}
actionLocation := path.Join(actionDir, actionPath) actionLocation := path.Join(actionDir, actionPath)
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc) actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
@@ -185,7 +187,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err 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) logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv()) rc.ApplyExtraPath(ctx, step.getEnv())
@@ -232,6 +234,11 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
} }
} }
// /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 // https://github.com/nektos/act/issues/228#issuecomment-629709055
// files in .gitignore are not copied in a Docker container // files in .gitignore are not copied in a Docker container
// this causes issues with actions that ignore other important resources // this causes issues with actions that ignore other important resources
@@ -347,19 +354,35 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
} }
} }
eval := rc.NewActionInputsExpressionEvaluator(ctx, step) eval := rc.NewActionInputsExpressionEvaluator(ctx, step)
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"])) args, err := eval.Interpolate(ctx, step.getStepModel().With["args"])
if err != nil {
return fmt.Errorf("unable to interpolate with.args: %w", err)
}
cmd, err := shellquote.Split(args)
if err != nil { if err != nil {
return err return err
} }
if len(cmd) == 0 { ee, err := evalDockerEnv(ctx, step, action)
cmd = action.Runs.Args if err != nil {
evalDockerArgs(ctx, step, action, &cmd) return err
}
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(ctx, step, eval, stage) entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
if err != nil { if err != nil {
return err return err
} }
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions) stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
if err != nil {
return err
}
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
prepImage, prepImage,
stepContainer.Pull(forcePull), stepContainer.Pull(forcePull),
@@ -381,10 +404,16 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
case stepStagePost: case stepStagePost:
entrypoint = runs.PostEntrypoint entrypoint = runs.PostEntrypoint
default: default:
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
return fields, nil
}
entrypoint = runs.Entrypoint entrypoint = runs.Entrypoint
if entrypoint == "" {
withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])
if err != nil {
return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
}
if fields := strings.Fields(withEntrypoint); len(fields) > 0 {
return fields, nil
}
}
} }
if entrypoint == "" { if entrypoint == "" {
@@ -393,36 +422,41 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
return shellquote.Split(entrypoint) 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() rc := step.getRunContext()
stepModel := step.getStepModel() stepModel := step.getStepModel()
var err error
inputs := make(map[string]string) inputs := make(map[string]string)
eval := rc.NewExpressionEvaluator(ctx) eval := rc.NewExpressionEvaluator(ctx)
// Set Defaults // Set Defaults
for k, input := range action.Inputs { for k, input := range action.Inputs {
inputs[k] = eval.Interpolate(ctx, input.Default) 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)
}
} }
if stepModel.With != nil { for k, v := range stepModel.With {
for k, v := range stepModel.With { if inputs[k], err = eval.Interpolate(ctx, v); err != nil {
inputs[k] = eval.Interpolate(ctx, v) return nil, fmt.Errorf("unable to interpolate with.%s: %w", k, err)
} }
} }
mergeIntoMap(step, step.getEnv(), inputs) mergeIntoMap(step, step.getEnv(), inputs)
stepEE := rc.NewActionInputsExpressionEvaluator(ctx, step) env := make(map[string]string, len(action.Runs.Env)+len(*step.getEnv()))
for i, v := range *cmd { mergeIntoMap(step, &env, action.Runs.Env, *step.getEnv())
(*cmd)[i] = stepEE.Interpolate(ctx, v) *step.getEnv() = env
}
mergeIntoMap(step, step.getEnv(), action.Runs.Env)
ee := rc.NewActionInputsExpressionEvaluator(ctx, step) ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
for k, v := range *step.getEnv() { for k, v := range *step.getEnv() {
(*step.getEnv())[k] = ee.Interpolate(ctx, v) if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil {
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
}
} }
return ee, nil
} }
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container { func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) (container.Container, error) {
rc := step.getRunContext() rc := step.getRunContext()
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
envList := make([]string, 0) envList := make([]string, 0)
@@ -432,9 +466,12 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
envList = append(envList, rc.runnerEnv(ctx)...) envList = append(envList, rc.runnerEnv(ctx)...)
binds, mounts := rc.GetBindsAndMounts() binds, mounts, err := rc.GetBindsAndMounts()
if err != nil {
return nil, err
}
networkMode := "container:" + rc.jobContainerName() networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv(ctx) { if rc.IsHostEnv() {
networkMode = "default" networkMode = "default"
} }
return ContainerNewContainer(&container.NewContainerInput{ return ContainerNewContainer(&container.NewContainerInput{
@@ -456,7 +493,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(), ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
}) }), nil
} }
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) { func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
@@ -469,15 +506,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) eval := rc.NewExpressionEvaluator(ctx)
for inputID, input := range action.Inputs { for inputID, input := range action.Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
envKey = "INPUT_" + envKey envKey = "INPUT_" + envKey
if _, ok := (*env)[envKey]; !ok { 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) { func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
@@ -577,16 +618,19 @@ func runPreStep(step actionStep) common.Executor {
actionDir, actionPath, _, containerActionDir := actionStagePaths(step) actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
x := action.Runs.Using 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 { switch {
case x.IsNode(): 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 { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err 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) logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv()) rc.ApplyExtraPath(ctx, step.getEnv())
@@ -594,14 +638,13 @@ func runPreStep(step actionStep) common.Executor {
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx) return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker(): 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) return execDockerActionStage(ctx, step, stepStagePre)
case x.IsComposite(): case x.IsComposite():
if step.getCompositeSteps() == nil { 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 { if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
@@ -610,9 +653,6 @@ func runPreStep(step actionStep) common.Executor {
return errors.New("missing steps in composite action") return errors.New("missing steps in composite action")
case x == model.ActionRunsUsingGo: 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 { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
} }
@@ -687,7 +727,7 @@ func runPostStep(step actionStep) common.Executor {
populateEnvsFromSavedState(step.getEnv(), step, rc) 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) logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv()) rc.ApplyExtraPath(ctx, step.getEnv())
+55 -24
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"regexp" "regexp"
"slices" "slices"
"strconv" "strconv"
@@ -17,7 +18,7 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
) )
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string { func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) (map[string]string, error) {
env := make(map[string]string) env := make(map[string]string)
stepEnv := *step.getEnv() stepEnv := *step.getEnv()
for k, v := range stepEnv { for k, v := range stepEnv {
@@ -36,23 +37,44 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
// lookup if key is defined in the step but the already // lookup if key is defined in the step but the already
// evaluated value from the environment // evaluated value from the environment
_, defined := step.getStepModel().With[inputID] defined := false
for key := range step.getStepModel().With {
if strings.EqualFold(key, inputID) {
defined = true
break
}
}
if value, ok := stepEnv[envKey]; defined && ok { if value, ok := stepEnv[envKey]; defined && ok {
env[envKey] = value env[envKey] = value
} else { } else {
// defaults could contain expressions // defaults could contain expressions
env[envKey] = ee.Interpolate(ctx, input.Default) var err error
if env[envKey], err = ee.Interpolate(ctx, input.Default); err != nil {
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
}
} }
} }
gh := step.getGithubContext(ctx) gh := step.getGithubContext(ctx)
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
env["GITHUB_ACTION_REF"] = gh.ActionRef env["GITHUB_ACTION_REF"] = gh.ActionRef
return env return env, nil
} }
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) *RunContext { func (rc *RunContext) setCompositeActionEnv(env map[string]string) {
env := evaluateCompositeInputAndEnv(ctx, parent, step) rc.setActionEnv(env)
for key := range rc.Env {
if strings.HasPrefix(key, "INPUT_") {
delete(rc.Env, key)
}
}
}
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) (*RunContext, error) {
env, err := evaluateCompositeInputAndEnv(ctx, parent, step)
if err != nil {
return nil, err
}
// run with the global config but without secrets // run with the global config but without secrets
configCopy := *parent.Config configCopy := *parent.Config
@@ -62,29 +84,31 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
compositerc := &RunContext{ compositerc := &RunContext{
Name: parent.Name, Name: parent.Name,
JobName: parent.JobName, JobName: parent.JobName,
Matrix: parent.Matrix,
Run: &model.Run{ Run: &model.Run{
JobID: parent.Run.JobID, JobID: parent.Run.JobID,
Workflow: &model.Workflow{ Workflow: &model.Workflow{
Name: parent.Run.Workflow.Name, Name: parent.Run.Workflow.Name,
Jobs: map[string]*model.Job{ Jobs: map[string]*model.Job{
parent.Run.JobID: {}, parent.Run.JobID: {Strategy: parent.Run.Job().Strategy},
}, },
}, },
}, },
Config: &configCopy, Config: &configCopy,
StepResults: map[string]*model.StepResult{}, StepResults: map[string]*model.StepResult{},
JobContainer: parent.JobContainer, JobContainer: parent.JobContainer,
ActionPath: actionPath, ActionPath: actionPath,
GlobalEnv: parent.GlobalEnv, GlobalEnv: parent.GlobalEnv,
Masks: parent.Masks, Masks: parent.Masks,
ExtraPath: parent.ExtraPath, ExtraPath: parent.ExtraPath,
Parent: parent, Parent: parent,
EventJSON: parent.EventJSON, EventJSON: parent.EventJSON,
platformImage: parent.platformImage,
} }
compositerc.setActionEnv(env) compositerc.setCompositeActionEnv(env)
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx) compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
return compositerc return compositerc, nil
} }
// appendUniqueMasks appends the masks from src to dst, skipping any mask that // appendUniqueMasks appends the masks from src to dst, skipping any mask that
@@ -105,7 +129,10 @@ func execAsComposite(step actionStep) common.Executor {
action := step.getActionModel() action := step.getActionModel()
return func(ctx context.Context) error { return func(ctx context.Context) error {
compositeRC := step.getCompositeRunContext(ctx) compositeRC, err := step.getCompositeRunContext(ctx)
if err != nil {
return err
}
steps := step.getCompositeSteps() steps := step.getCompositeSteps()
@@ -115,14 +142,17 @@ func execAsComposite(step actionStep) common.Executor {
ctx = WithCompositeLogger(ctx, &compositeRC.Masks) ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
err := steps.main(ctx) err = steps.main(ctx)
// Map outputs from composite RunContext to job RunContext // Map outputs from composite RunContext to job RunContext
eval := compositeRC.NewExpressionEvaluator(ctx) eval := compositeRC.NewExpressionEvaluator(ctx)
for outputName, output := range action.Outputs { for outputName, output := range action.Outputs {
rc.setOutput(ctx, map[string]string{ value, outputErr := eval.Interpolate(ctx, output.Value)
"name": outputName, if outputErr != nil {
}, eval.Interpolate(ctx, output.Value)) err = errors.Join(err, fmt.Errorf("unable to interpolate output %s: %w", outputName, outputErr))
continue
}
rc.setOutput(ctx, map[string]string{"name": outputName}, value)
} }
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext) // compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
@@ -131,7 +161,7 @@ func execAsComposite(step actionStep) common.Executor {
// repeated composite actions grow rc.Masks exponentially. // repeated composite actions grow rc.Masks exponentially.
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks) rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
rc.ExtraPath = compositeRC.ExtraPath rc.ExtraPath = compositeRC.ExtraPath
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv // Propagate GlobalEnv only, so composite inputs and step-local values do not escape.
mergeIntoMap := mergeIntoMapCaseSensitive mergeIntoMap := mergeIntoMapCaseSensitive
if rc.JobContainer.IsEnvironmentCaseInsensitive() { if rc.JobContainer.IsEnvironmentCaseInsensitive() {
mergeIntoMap = mergeIntoMapCaseInsensitive mergeIntoMap = mergeIntoMapCaseInsensitive
@@ -194,6 +224,7 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
} }
steps = append(steps, common.JobError) steps = append(steps, common.JobError)
preSteps = append(preSteps, common.JobError)
return &compositeSteps{ return &compositeSteps{
pre: func(ctx context.Context) error { pre: func(ctx context.Context) error {
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx)) return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
+49
View File
@@ -6,9 +6,58 @@ package runner
import ( import (
"testing" "testing"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestCompositeActionParity(t *testing.T) {
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
ctx := t.Context()
strategy := &model.Strategy{MaxParallel: 3}
parent := &RunContext{
Config: &Config{},
Matrix: map[string]any{"os": "linux"},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
JobContainer: &jobContainerMock{},
platformImage: "-self-hosted",
}
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
RunContext: parent,
action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}},
env: map[string]string{"INPUT_SHARED": "outer"},
}, "/action")
require.NoError(t, err)
assert.Same(t, strategy, composite.Run.Job().Strategy)
assert.True(t, composite.IsHostEnv())
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}")
require.NoError(t, err)
assert.Equal(t, "linux|3|outer", interpolated)
assert.NotContains(t, composite.Env, "INPUT_SHARED")
nestedEnv := composite.GetEnv()
require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite))
assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"])
})
t.Run("propagates pre failures", func(t *testing.T) {
setCloneExecutor(t, func(git.NewGitCloneExecutorInput) common.Executor { return common.NewErrorExecutor(assert.AnError) })
rc := &RunContext{
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir()},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
JobContainer: &jobContainerMock{},
}
require.ErrorIs(t, rc.compositeExecutor(&model.Action{Runs: model.ActionRuns{Using: "composite", Steps: []model.Step{{ID: "nested", Uses: "org/action@v1"}}}}).pre(t.Context()), assert.AnError)
})
}
func TestAppendUniqueMasks(t *testing.T) { func TestAppendUniqueMasks(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+84 -25
View File
@@ -8,6 +8,10 @@ import (
"context" "context"
"io" "io"
"io/fs" "io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -223,7 +227,7 @@ func TestActionRunner(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cm := &containerMock{} cm := &containerMock{}
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil }) cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false, true).Return(func(ctx context.Context) error { return nil })
envMatcher := mock.MatchedBy(func(env map[string]string) bool { envMatcher := mock.MatchedBy(func(env map[string]string) bool {
for k, v := range tt.expectedEnv { for k, v := range tt.expectedEnv {
@@ -234,7 +238,7 @@ func TestActionRunner(t *testing.T) {
return true return true
}) })
cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil }) cm.On("Exec", []string{"node", "--preserve-symlinks-main", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
tt.step.getRunContext().JobContainer = cm tt.step.getRunContext().JobContainer = cm
@@ -246,6 +250,32 @@ func TestActionRunner(t *testing.T) {
} }
} }
func TestNodeActionCommandPreservesSymlinkedEntrypoint(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on Windows")
}
requireHostTools(t, "node")
actionDir := t.TempDir()
entrypoint := filepath.Join(actionDir, "index.mjs")
require.NoError(t, os.WriteFile(entrypoint, []byte(`
import {fileURLToPath} from "node:url";
const self = fileURLToPath(import.meta.url);
if (process.argv[1] !== self) {
console.log("argv[1]:", process.argv[1], "import.meta.url:", self);
process.exitCode = 1;
}
`), 0o600))
symlinkedActionDir := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.Symlink(actionDir, symlinkedActionDir))
symlinkedEntrypoint := filepath.Join(symlinkedActionDir, "index.mjs")
args := nodeActionCommand(symlinkedEntrypoint)
output, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput()
require.NoError(t, err, "%s", output)
}
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) { func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
cm := &containerMock{} cm := &containerMock{}
@@ -286,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
step.On("getStepModel").Return(&model.Step{ID: "action"}) step.On("getStepModel").Return(&model.Step{ID: "action"})
step.On("getEnv").Return(&env) step.On("getEnv").Return(&env)
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "") _, _ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers. // DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
assert.Empty(t, captured.Username) assert.Empty(t, captured.Username)
@@ -307,7 +337,7 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
copyEntered := make(chan struct{}) copyEntered := make(chan struct{})
cm := &containerMock{} cm := &containerMock{}
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error { cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false, true).Return(func(ctx context.Context) error {
close(copyEntered) close(copyEntered)
<-releaseCopy <-releaseCopy
return nil return nil
@@ -492,8 +522,6 @@ func TestDockerActionImageTag(t *testing.T) {
) )
} }
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
// and runs.env, and the `entrypoint` input applies to the main stage alone.
func TestExecAsDockerStageEntrypoint(t *testing.T) { func TestExecAsDockerStageEntrypoint(t *testing.T) {
orig := ContainerNewContainer orig := ContainerNewContainer
defer func() { ContainerNewContainer = orig }() defer func() { ContainerNewContainer = orig }()
@@ -501,25 +529,62 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
stage stepStage stage stepStage
with map[string]string
runs model.ActionRuns
env map[string]string
wantCmd []string
wantEntrypoint []string wantEntrypoint []string
}{ }{
{ {
name: "main stage prefers the entrypoint input", name: "main stage prefers manifest values",
stage: stepStageMain, stage: stepStageMain,
wantEntrypoint: []string{"input.sh"}, with: map[string]string{"args": "caller", "entrypoint": "input.sh"},
runs: model.ActionRuns{
Entrypoint: "main.sh",
Args: []string{"manifest"},
Env: map[string]string{"ACTION_ONLY": "manifest"},
},
wantCmd: []string{"manifest"},
wantEntrypoint: []string{"main.sh"},
}, },
{ {
name: "pre stage uses runs.pre-entrypoint", name: "main stage uses caller fallbacks",
stage: stepStagePre, stage: stepStageMain,
with: map[string]string{"args": "caller --flag", "entrypoint": "input.sh --verbose"},
runs: model.ActionRuns{Env: map[string]string{"ACTION_ONLY": "manifest"}},
wantCmd: []string{"caller", "--flag"},
wantEntrypoint: []string{"input.sh", "--verbose"},
},
{
name: "explicit empty manifest args suppress caller args",
stage: stepStageMain,
with: map[string]string{"args": "caller"},
runs: model.ActionRuns{Args: []string{}},
wantCmd: []string{},
},
{
name: "pre stage keeps step environment",
stage: stepStagePre,
with: map[string]string{"entrypoint": "input.sh"},
runs: model.ActionRuns{
PreEntrypoint: "pre.sh --verbose",
Args: []string{"hello"},
Env: map[string]string{"SHARED": "manifest"},
},
env: map[string]string{"SHARED": "step"},
wantCmd: []string{"hello"},
wantEntrypoint: []string{"pre.sh", "--verbose"}, wantEntrypoint: []string{"pre.sh", "--verbose"},
}, },
{ {
name: "post stage uses runs.post-entrypoint", name: "post stage uses manifest entrypoint",
stage: stepStagePost, stage: stepStagePost,
runs: model.ActionRuns{PostEntrypoint: "post.sh", Args: []string{"hello"}},
wantCmd: []string{"hello"},
wantEntrypoint: []string{"post.sh"}, wantEntrypoint: []string{"post.sh"},
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
tc.runs.Using, tc.runs.Image = "docker", "docker://node:14"
cm := &containerMock{} cm := &containerMock{}
var input *container.NewContainerInput var input *container.NewContainerInput
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment { ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
@@ -528,22 +593,14 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
} }
step := &stepActionRemote{ step := &stepActionRemote{
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}}, Step: &model.Step{ID: "1", Uses: "org/action@v1", With: tc.with},
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{}, Config: &Config{},
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}}, Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
JobContainer: cm, JobContainer: cm,
}, },
action: &model.Action{Runs: model.ActionRuns{ action: &model.Action{Runs: tc.runs},
Using: "docker", env: mergeMaps(tc.env),
Image: "docker://node:14",
PreEntrypoint: "pre.sh --verbose",
Entrypoint: "main.sh",
PostEntrypoint: "post.sh",
Args: []string{"hello"},
Env: map[string]string{"MY_VAR": "world"},
}},
env: map[string]string{},
} }
cm.On("Pull", false).Return(func(context.Context) error { return nil }) cm.On("Pull", false).Return(func(context.Context) error { return nil })
@@ -554,9 +611,11 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage)) require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
require.NotNil(t, input) require.NotNil(t, input)
assert.Equal(t, tc.wantCmd, input.Cmd)
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint) assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
assert.Equal(t, []string{"hello"}, input.Cmd) for key, value := range mergeMaps(tc.runs.Env, tc.env) {
assert.Contains(t, input.Env, "MY_VAR=world") assert.Contains(t, input.Env, key+"="+value)
}
}) })
} }
} }
+2
View File
@@ -61,6 +61,8 @@ func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess) enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job") assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
setJobResult(context.Background(), rc, rc, true)
assert.Equal(t, "cancelled", rc.Run.Job().Result)
} }
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does // TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
+43 -12
View File
@@ -18,11 +18,17 @@ var commandPatternGA *regexp.Regexp
var commandPatternADO *regexp.Regexp var commandPatternADO *regexp.Regexp
func init() { func init() {
commandPatternGA = regexp.MustCompile("^::([^ ]+)( (.+))?::([^\r\n]*)[\r\n]+$") commandPatternGA = regexp.MustCompile("^::([^ ]+?)( (.+?))?::([^\r\n]*)[\r\n]*$")
commandPatternADO = regexp.MustCompile("^##\\[([^ ]+)( (.+))?]([^\r\n]*)[\r\n]+$") // excluding ']' ends the command info at the first bracket, as GitHub does
commandPatternADO = regexp.MustCompile("^##\\[([^ \\]]+)( ([^\\]]*))?]([^\r\n]*)[\r\n]*$")
} }
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) { func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
command, kvPairs, arg, _, ok = tryParseActionCommand(line)
return command, kvPairs, arg, ok
}
func tryParseActionCommand(line string) (command string, kvPairs map[string]string, arg string, legacy, ok bool) {
if m := commandPatternGA.FindStringSubmatch(line); m != nil { if m := commandPatternGA.FindStringSubmatch(line); m != nil {
command = m[1] command = m[1]
kvPairs = parseKeyValuePairs(m[3], ",") kvPairs = parseKeyValuePairs(m[3], ",")
@@ -32,19 +38,21 @@ func tryParseRawActionCommand(line string) (command string, kvPairs map[string]s
command = m[1] command = m[1]
kvPairs = parseKeyValuePairs(m[3], ";") kvPairs = parseKeyValuePairs(m[3], ";")
arg = m[4] arg = m[4]
legacy = true
ok = true ok = true
} }
return command, kvPairs, arg, ok return command, kvPairs, arg, legacy, ok
} }
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler { func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
logger := common.Logger(ctx) logger := common.Logger(ctx)
resumeCommand := "" resumeCommand := ""
return func(line string) bool { return func(line string) bool {
command, kvPairs, arg, ok := tryParseRawActionCommand(line) command, kvPairs, arg, legacy, ok := tryParseActionCommand(line)
if !ok { if !ok {
return true return true
} }
command = strings.ToLower(command)
if resumeCommand != "" { if resumeCommand != "" {
// There should not be any emojis in the log output for Gitea. // There should not be any emojis in the log output for Gitea.
@@ -54,19 +62,24 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
logger.Infof("%s", line) logger.Infof("%s", line)
// Resumed here rather than from the switch, because the end token is arbitrary // Resumed here rather than from the switch, because the end token is arbitrary
// and a token naming a real command would otherwise never resume. // and a token naming a real command would otherwise never resume.
if command == resumeCommand { if strings.EqualFold(command, resumeCommand) {
resumeCommand = "" resumeCommand = ""
} }
return true return true
} }
arg = UnescapeCommandData(arg) if legacy {
kvPairs = unescapeKvPairs(kvPairs) arg = UnescapeLegacyCommand(arg)
kvPairs = unescapeKvPairs(kvPairs, UnescapeLegacyCommand)
} else {
arg = UnescapeCommandData(arg)
kvPairs = unescapeKvPairs(kvPairs, unescapeCommandProperty)
}
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) { if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
return true return true
} }
switch command { switch command {
case "set-env": case "set-env":
rc.setEnv(ctx, kvPairs, arg) rc.setEnv(ctx, kvPairs, arg, true)
case "set-output": case "set-output":
rc.setOutput(ctx, kvPairs, arg) rc.setOutput(ctx, kvPairs, arg)
case "add-path": case "add-path":
@@ -139,8 +152,16 @@ func (rc *RunContext) takeUnsecureCommandError() error {
return err return err
} }
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) { func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string, fromCommand bool) {
name := kvPairs["name"] name := kvPairs["name"]
if strings.EqualFold(name, "NODE_OPTIONS") {
message := "Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command."
if fromCommand {
message = "Can't update NODE_OPTIONS environment variable using ::set-env:: command."
}
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message))
return
}
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg) common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
if rc.Env == nil { if rc.Env == nil {
rc.Env = make(map[string]string) rc.Env = make(map[string]string)
@@ -159,6 +180,10 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
mergeIntoMap(rc.GlobalEnv, newenv) mergeIntoMap(rc.GlobalEnv, newenv)
} }
func (rc *RunContext) setEnvFile(ctx context.Context, kvPairs map[string]string, arg string) {
rc.setEnv(ctx, kvPairs, arg, false)
}
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) { func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
stepID := rc.CurrentStep stepID := rc.CurrentStep
@@ -189,7 +214,7 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
rtn := make(map[string]string) rtn := make(map[string]string)
kvPairList := strings.SplitSeq(kvPairs, separator) kvPairList := strings.SplitSeq(kvPairs, separator)
for kvPair := range kvPairList { for kvPair := range kvPairList {
kv := strings.Split(kvPair, "=") kv := strings.SplitN(kvPair, "=", 2)
if len(kv) == 2 { if len(kv) == 2 {
rtn[kv[0]] = kv[1] rtn[kv[0]] = kv[1]
} }
@@ -202,6 +227,7 @@ var (
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A") commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n") commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",") commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
legacyCommandUnescaper = strings.NewReplacer("%3B", ";", "%0D", "\r", "%0A", "\n", "%5D", "]", "%25", "%")
) )
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself, // EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
@@ -218,9 +244,14 @@ func unescapeCommandProperty(arg string) string {
return commandPropertyUnescaper.Replace(arg) return commandPropertyUnescaper.Replace(arg)
} }
func unescapeKvPairs(kvPairs map[string]string) map[string]string { // UnescapeLegacyCommand decodes a "##[cmd]" line, which also spells ";" and "]" escaped.
func UnescapeLegacyCommand(arg string) string {
return legacyCommandUnescaper.Replace(arg)
}
func unescapeKvPairs(kvPairs map[string]string, unescape func(string) string) map[string]string {
for k, v := range kvPairs { for k, v := range kvPairs {
kvPairs[k] = unescapeCommandProperty(v) kvPairs[k] = unescape(v)
} }
return kvPairs return kvPairs
} }
+23 -4
View File
@@ -26,12 +26,21 @@ func unsecureRC() *RunContext {
func TestSetEnv(t *testing.T) { func TestSetEnv(t *testing.T) {
a := assert.New(t) a := assert.New(t)
ctx := context.Background() logger, hook := test.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
rc := unsecureRC() rc := unsecureRC()
handler := rc.commandHandler(ctx) handler := rc.commandHandler(ctx)
handler("::set-env name=x::valz\n") handler("::set-env name=x::valz\n")
a.Equal("valz", rc.Env["x"]) a.Equal("valz", rc.Env["x"])
handler("::SET-ENV name=NODE_OPTIONS::--require command.js\n")
rc.setEnvFile(ctx, map[string]string{"name": "node_options"}, "--require env.js")
a.NotContains(rc.Env, "NODE_OPTIONS")
a.NotContains(rc.Env, "node_options")
entries := hook.AllEntries()
require.Len(t, entries, 3)
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[1].Message)
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[2].Message)
} }
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) { func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
@@ -85,6 +94,16 @@ func TestSetOutput(t *testing.T) {
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n") handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"]) a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
handler("::set-output name=symbol::std::vector")
a.Equal("std::vector", rc.StepResults["my-step"].Outputs["symbol"])
handler("::set-output name=a=b::value\n")
a.Equal("value", rc.StepResults["my-step"].Outputs["a=b"])
handler("##[set-output name=legacy%3B%5D]value%3B%5D")
a.Equal("value;]", rc.StepResults["my-step"].Outputs["legacy;]"])
handler("##[set-output name=bracket]value]tail")
a.Equal("value]tail", rc.StepResults["my-step"].Outputs["bracket"])
handler("::set-output name=modern%3B%5D::value%3B%5D\n")
a.Equal("value%3B%5D", rc.StepResults["my-step"].Outputs["modern%3B%5D"])
} }
func TestAddpath(t *testing.T) { func TestAddpath(t *testing.T) {
@@ -110,7 +129,7 @@ func TestStopCommands(t *testing.T) {
handler("::set-env name=x::valz\n") handler("::set-env name=x::valz\n")
a.Equal("valz", rc.Env["x"]) a.Equal("valz", rc.Env["x"])
handler("::stop-commands::my-end-token\n") handler("::stop-commands::MY-END-TOKEN\n")
handler("::set-env name=x::abcd\n") handler("::set-env name=x::abcd\n")
a.Equal("valz", rc.Env["x"]) a.Equal("valz", rc.Env["x"])
handler("::my-end-token::\n") handler("::my-end-token::\n")
@@ -163,10 +182,10 @@ func TestAddmask(t *testing.T) {
rc := new(RunContext) rc := new(RunContext)
handler := rc.commandHandler(loggerCtx) handler := rc.commandHandler(loggerCtx)
handler("::add-mask::my-secret-value\n") handler("::ADD-MASK::my::secret")
a.Equal("***", hook.LastEntry().Message) a.Equal("***", hook.LastEntry().Message)
a.NotEqual("*my-secret-value", hook.LastEntry().Message) a.Equal([]string{"my::secret"}, rc.Masks)
} }
// based on https://stackoverflow.com/a/10476304 // based on https://stackoverflow.com/a/10476304
+2 -2
View File
@@ -57,8 +57,8 @@ func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) co
return args.Get(0).(func(context.Context) error) return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor { func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
args := cm.Called(destPath, srcPath, useGitIgnore) args := cm.Called(destPath, srcPath, useGitIgnore, skipGitDir)
return args.Get(0).(func(context.Context) error) return args.Get(0).(func(context.Context) error)
} }
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package runner
import (
"context"
"crypto/rand"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/model"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/client"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDockerProxyMountedJob(t *testing.T) {
mode := os.Getenv("ACT_TEST_DOCKER_PROXY")
if mode == "" {
t.Skip("set ACT_TEST_DOCKER_PROXY=proxy or direct to verify mounted Docker access")
}
require.Contains(t, []string{"proxy", "direct"}, mode)
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
defer cancel()
dockerClient, err := container.GetDockerClient(ctx)
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, dockerClient.Close()) })
require.True(t, strings.HasPrefix(dockerClient.DaemonHost(), "unix://"), "mounted Docker coverage requires a Unix daemon socket")
fixtureDir, err := filepath.Abs("testdata/docker-proxy")
require.NoError(t, err)
resourceName := "gitea-proxy-test-" + strings.ToLower(rand.Text())
runner, err := New(&Config{
Workdir: fixtureDir,
ActionCacheDir: t.TempDir(),
EventName: "push",
PlatformPicker: mapPlatformPicker(platforms),
ContainerNamePrefix: resourceName,
ContainerDaemonSocket: dockerClient.DaemonHost(),
ContainerMaxLifetime: 2 * time.Minute,
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode},
})
require.NoError(t, err)
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
require.NoError(t, err)
plan, err := planner.PlanEvent("push")
require.NoError(t, err)
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
require.NoError(t, err)
jobName := runContext.jobContainerName()
jobNetwork, _ := runContext.networkNameForGitea()
t.Cleanup(func() {
cleanCtx, cleanCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cleanCancel()
if _, err := dockerClient.ContainerRemove(cleanCtx, jobName, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); !cerrdefs.IsNotFound(err) {
assert.NoError(t, err)
}
for _, name := range []string{resourceName, jobNetwork} {
if _, err := dockerClient.NetworkRemove(cleanCtx, name, client.NetworkRemoveOptions{}); !cerrdefs.IsNotFound(err) {
assert.NoError(t, err)
}
}
for _, name := range []string{resourceName, jobName, jobName + "-env"} {
if _, err := dockerClient.VolumeRemove(cleanCtx, name, client.VolumeRemoveOptions{}); !cerrdefs.IsNotFound(err) {
assert.NoError(t, err)
}
}
})
hook := &test.Hook{}
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHook(ctx, hook)))
var messages []string
for _, entry := range hook.AllEntries() {
messages = append(messages, strings.TrimSpace(entry.Message))
}
require.Contains(t, messages, "docker proxy post verified")
_, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err)
_, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{})
if mode == "proxy" {
assert.True(t, cerrdefs.IsNotFound(err), "labelled network survived cleanup: %v", err)
} else {
require.NoError(t, err)
}
_, err = dockerClient.VolumeInspect(ctx, resourceName, client.VolumeInspectOptions{})
if mode == "proxy" {
assert.True(t, cerrdefs.IsNotFound(err), "labelled volume survived cleanup: %v", err)
} else {
require.NoError(t, err)
}
}
+51 -54
View File
@@ -13,6 +13,7 @@ import (
"reflect" "reflect"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -71,7 +72,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
} }
ghc := rc.getGithubContext(ctx) ghc := rc.getGithubContext(ctx)
inputs := getEvaluatorInputs(ctx, rc, rc.actionInputs, ghc) inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc)
ee := &exprparser.EvaluationEnvironment{ ee := &exprparser.EvaluationEnvironment{
Github: ghc, Github: ghc,
@@ -81,7 +82,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
// todo: should be unavailable // todo: should be unavailable
// but required to interpolate/evaluate the step outputs on the job // but required to interpolate/evaluate the step outputs on the job
Steps: rc.getStepsContext(), Steps: rc.getStepsContext(),
Secrets: getWorkflowSecrets(ctx, rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: strategy,
Matrix: rc.Matrix, Matrix: rc.Matrix,
@@ -137,14 +138,14 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
Env: *step.getEnv(), Env: *step.getEnv(),
Job: rc.getJobContext(), Job: rc.getJobContext(),
Steps: rc.getStepsContext(), Steps: rc.getStepsContext(),
Secrets: getWorkflowSecrets(ctx, rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: strategy,
Matrix: rc.Matrix, Matrix: rc.Matrix,
Needs: using, Needs: using,
// todo: should be unavailable // todo: should be unavailable
// but required to interpolate/evaluate the inputs in actions/composite // but required to interpolate/evaluate the inputs in actions/composite
Inputs: getEvaluatorInputs(ctx, rc, stepInputs, rc.getGithubContext(ctx)), Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
HashFiles: getHashFilesFunction(ctx, rc), HashFiles: getHashFilesFunction(ctx, rc),
} }
ee.Runner = rc.getRunnerContext(ctx) ee.Runner = rc.getRunnerContext(ctx)
@@ -246,17 +247,18 @@ func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.N
return ee.shared(ctx).EvaluateYamlNode(node) return ee.shared(ctx).EvaluateYamlNode(node)
} }
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string { func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) (string, error) {
out, err := ee.interpolate(ctx, in) return ee.shared(ctx).Interpolate(in)
if err != nil {
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", in, err)
return ""
}
return out
} }
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) { // InterpolateName keeps the source text of a job or step name that cannot be evaluated, as GitHub does.
return ee.shared(ctx).Interpolate(in) func (ee expressionEvaluator) InterpolateName(ctx context.Context, in string) string {
out, err := ee.Interpolate(ctx, in)
if err != nil {
common.Logger(ctx).Warnf("Unable to evaluate the display name '%s': %s", in, err)
return in
}
return out
} }
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without // EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
@@ -277,10 +279,10 @@ func inputsFromEnv(env map[string]string) map[string]any {
return inputs return inputs
} }
func getEvaluatorInputs(ctx context.Context, rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any { func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
inputs := map[string]any{} inputs := map[string]any{}
setupWorkflowInputs(ctx, &inputs, rc) maps.Copy(inputs, rc.workflowCallInputs)
maps.Copy(inputs, stepInputs) maps.Copy(inputs, stepInputs)
if ghc.EventName == "workflow_dispatch" { if ghc.EventName == "workflow_dispatch" {
@@ -324,54 +326,49 @@ func coerceInputValue(value any, inputType string) any {
return value == "true" return value == "true"
} }
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) { // resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
if rc.caller != nil { func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
config := rc.Run.Workflow.WorkflowCallConfig() if rc.caller == nil {
return nil
}
callerJob := rc.caller.runContext.Run.Job()
callerEval := rc.caller.runContext.ExprEval
calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
config := rc.Run.Workflow.WorkflowCallConfig()
for name, input := range config.Inputs { rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
value := rc.caller.runContext.Run.Job().With[name] for name, input := range config.Inputs {
if value != nil { value, eval, label := callerJob.With[name], callerEval, "input"
if str, ok := value.(string); ok { if value == nil {
// evaluate using the calling RunContext (outside) value, eval, label = input.Default, calleeEval(), "the default of input"
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str) }
} if str, ok := value.(string); ok {
var err error
if value, err = eval.Interpolate(ctx, str); err != nil {
return fmt.Errorf("unable to interpolate %s %s: %w", label, name, err)
} }
}
rc.workflowCallInputs[name] = coerceInputValue(value, input.Type)
}
if value == nil && config != nil && config.Inputs != nil { secrets := callerJob.Secrets()
value = input.Default if secrets == nil && callerJob.InheritSecrets() {
if rc.ExprEval != nil { secrets = rc.caller.runContext.Config.Secrets
if str, ok := value.(string); ok { }
// evaluate using the called RunContext (inside) rc.workflowCallSecrets = make(map[string]string, len(secrets))
value = rc.ExprEval.Interpolate(ctx, str) for k, v := range secrets {
} var err error
} if rc.workflowCallSecrets[k], err = callerEval.Interpolate(ctx, v); err != nil {
} return fmt.Errorf("unable to interpolate secret %s: %w", k, err)
(*inputs)[name] = coerceInputValue(value, input.Type)
} }
} }
return nil
} }
func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string { func getWorkflowSecrets(rc *RunContext) map[string]string {
if rc.caller != nil { if rc.caller != nil {
job := rc.caller.runContext.Run.Job() return rc.workflowCallSecrets
secrets := job.Secrets()
if secrets == nil && job.InheritSecrets() {
secrets = rc.caller.runContext.Config.Secrets
}
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
// in place is a data race.
interpolated := make(map[string]string, len(secrets))
for k, v := range secrets {
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
}
return interpolated
} }
return rc.Config.Secrets return rc.Config.Secrets
} }
+11 -8
View File
@@ -266,19 +266,21 @@ func TestInterpolate(t *testing.T) {
{"${{ null }}", ""}, {"${{ null }}", ""},
{"${{ fromJSON('[1,2]') }}", "Array"}, {"${{ fromJSON('[1,2]') }}", "Array"},
{"${{ fromJSON('{\"a\":1}') }}", "Object"}, {"${{ fromJSON('{\"a\":1}') }}", "Object"},
// a malformed part must not restructure its neighbours, and it interpolates to nothing
{"${{ 1) && (2 }}", ""},
{"run ${{ 1) && (2 }} now", ""},
{"${{ 1", "${{ 1"}, {"${{ 1", "${{ 1"},
} }
for _, table := range tables { for _, table := range tables {
t.Run("interpolate", func(t *testing.T) { t.Run("interpolate", func(t *testing.T) {
assertObject := assert.New(t) out, err := ee.Interpolate(context.Background(), table.in)
out := ee.Interpolate(context.Background(), table.in) require.NoError(t, err, table.in)
assertObject.Equal(table.out, out, table.in) assert.Equal(t, table.out, out, table.in)
}) })
} }
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now"} {
_, err := ee.Interpolate(context.Background(), in)
assert.Error(t, err, in)
}
} }
func TestGetEvaluatorInputsBoolean(t *testing.T) { func TestGetEvaluatorInputsBoolean(t *testing.T) {
@@ -352,7 +354,7 @@ on:
} }
ghc := &model.GithubContext{EventName: eventName, Event: table.event} ghc := &model.GithubContext{EventName: eventName, Event: table.event}
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc) inputs := getEvaluatorInputs(rc, nil, ghc)
assert.Equal(t, table.flag, inputs["flag"]) assert.Equal(t, table.flag, inputs["flag"])
assert.Equal(t, "gitea", inputs["name"]) assert.Equal(t, "gitea", inputs["name"])
}) })
@@ -372,7 +374,8 @@ jobs:
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}} runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
containerName := func(jobID string) string { containerName := func(jobID string) string {
rc := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil) rc, err := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
require.NoError(t, err)
assert.NotContains(t, rc.Name, "s3cr3t") assert.NotContains(t, rc.Name, "s3cr3t")
return rc.jobContainerName() return rc.jobContainerName()
} }
+78 -36
View File
@@ -18,6 +18,7 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
@@ -154,11 +155,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
if rc.Run == nil { if rc.Run == nil {
return nil return nil
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) if err := evaluateJobEnvAndDefaults(ctx, rc); err != nil {
// evaluate environment variables since they can contain reportStepError(ctx, rc, err)
// GitHub's special environment variables. return err
for k, v := range rc.GetEnv() {
rc.Env[k] = rc.ExprEval.Interpolate(ctx, v)
} }
return nil return nil
}) })
@@ -236,6 +235,15 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
// Ahead of the teardown below, while the job environment is still up. // Ahead of the teardown below, while the job environment is still up.
postExecutor = postExecutor.Finally(rc.runJobCompletedHook) postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
// swallowed: a bad output fails this job, it must not abandon the rest of the plan
if err := info.interpolateOutputs()(ctx); err != nil {
reportStepError(ctx, rc, err)
} else if err := setJobOutputs(ctx, rc); err != nil {
reportStepError(ctx, rc, err)
}
return nil
})
postExecutor = postExecutor.Finally(func(ctx context.Context) error { postExecutor = postExecutor.Finally(func(ctx context.Context) error {
jobError := common.JobError(ctx) jobError := common.JobError(ctx)
@@ -251,24 +259,23 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error())) logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
} }
setJobResult(ctx, info, rc, jobError == nil) setJobResult(ctx, info, rc, jobError == nil)
setJobOutputs(ctx, rc)
return err return err
}) })
stepsExecutor := newStepsExecutor(rc, preSteps, steps) return common.Executor(func(ctx context.Context) error {
if err := info.startContainer()(ctx); err != nil {
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor. return err
Finally(func(ctx context.Context) error { }
return newStepsExecutor(rc, preSteps, steps).Finally(func(ctx context.Context) error {
// Record an interrupt (backstop for interrupts that land outside the main // Record an interrupt (backstop for interrupts that land outside the main
// step loop) so the post steps observe the cancelled/failed job status. // step loop) so the post steps observe the cancelled/failed job status.
rc.markInterrupted(ctx.Err()) rc.markInterrupted(ctx.Err())
postCtx, cancel := postStepsContext(ctx) postCtx, cancel := postStepsContext(ctx)
defer cancel() defer cancel()
return postExecutor(postCtx) return postExecutor(postCtx)
}). })(ctx)
Finally(info.interpolateOutputs()). }).Finally(info.closeContainer())
Finally(info.closeContainer()))
} }
// postStepsContext derives the context used to run the job's post/cleanup steps from the // postStepsContext derives the context used to run the job's post/cleanup steps from the
@@ -365,7 +372,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
// concurrent succeeding one. // concurrent succeeding one.
job := rc.Run.Job() job := rc.Run.Job()
var continueOnError bool var continueOnError bool
if !success { if !success && !rc.jobCancelled {
// Use a fresh context so an expired job timeout cannot block expression evaluation. // Use a fresh context so an expired job timeout cannot block expression evaluation.
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx)) evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job) continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
@@ -378,7 +385,11 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
if len(info.matrix()) > 0 && job.Result != "" { if len(info.matrix()) > 0 && job.Result != "" {
result = job.Result result = job.Result
} }
if !success { // cancelled is sticky, so a sibling combination finishing last cannot mask it
switch {
case rc.jobCancelled:
result = "cancelled"
case !success && result != "cancelled":
result = "failure" result = "failure"
job.SetContinueOnError(continueOnError) job.SetContinueOnError(continueOnError)
} }
@@ -392,38 +403,69 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
return return
} }
jobResultMessage := "succeeded" jobResultMessage := "failed"
if jobResult != "success" { switch jobResult {
jobResultMessage = "failed" case "success":
jobResultMessage = "succeeded"
case "cancelled":
jobResultMessage = "cancelled"
} }
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage) logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
} }
func setJobOutputs(ctx context.Context, rc *RunContext) { // evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
if rc.caller != nil { func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
// map outputs for reusable workflows rc.ExprEval = rc.NewExpressionEvaluator(ctx)
callerOutputs := make(map[string]string) var err error
for k, v := range rc.GetEnv() {
ee := rc.NewExpressionEvaluator(ctx) if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
for k, v := range rc.Run.Workflow.WorkflowCallConfig().Outputs {
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
} }
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
// serialize the write so parallel combos don't race on its Outputs field.
callerJob := rc.caller.runContext.Run.Job()
defer lockJob(callerJob)()
callerJob.Outputs = callerOutputs
} }
defaults := rc.Run.Job().Defaults.Run
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil {
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err)
}
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil {
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err)
}
return nil
}
func setJobOutputs(ctx context.Context, rc *RunContext) error {
if rc.caller == nil {
return nil
}
outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs
callerOutputs := make(map[string]string, len(outputs))
ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
for k, v := range outputs {
value := v.Value
for range 2 { // two passes, the value resolves through a job output
var err error
if value, err = ee().Interpolate(ctx, value); err != nil {
return fmt.Errorf("unable to interpolate workflow output %s: %w", k, err)
}
}
callerOutputs[k] = value
}
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
// serialize the write so parallel combos don't race on its Outputs field.
callerJob := rc.caller.runContext.Run.Job()
defer lockJob(callerJob)()
callerJob.Outputs = callerOutputs
return nil
} }
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the // applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
// step-level evaluateStepTimeout in step.go. // step-level evaluateStepTimeout in step.go.
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) { func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes) timeout, err := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
if timeout != "" { if err != nil {
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
} else if timeout != "" {
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil { if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute) return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
} }
@@ -622,7 +664,7 @@ func archiveEntryMatchesPath(entryName, requestedPath string) bool {
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor { func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String()) ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String())
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
+62 -11
View File
@@ -299,6 +299,9 @@ func TestNewJobExecutor(t *testing.T) {
executedSteps []string executedSteps []string
result string result string
hasError bool hasError bool
output string
startError error
cancelOnStart bool
}{ }{
{ {
name: "zeroSteps", name: "zeroSteps",
@@ -319,8 +322,8 @@ func TestNewJobExecutor(t *testing.T) {
executedSteps: []string{ executedSteps: []string{
"startContainer", "startContainer",
"step1", "step1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
@@ -336,8 +339,8 @@ func TestNewJobExecutor(t *testing.T) {
executedSteps: []string{ executedSteps: []string{
"startContainer", "startContainer",
"step1", "step1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "failure", result: "failure",
@@ -354,8 +357,8 @@ func TestNewJobExecutor(t *testing.T) {
"startContainer", "startContainer",
"pre1", "pre1",
"step1", "step1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
@@ -372,8 +375,8 @@ func TestNewJobExecutor(t *testing.T) {
"startContainer", "startContainer",
"step1", "step1",
"post1", "post1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
@@ -391,8 +394,8 @@ func TestNewJobExecutor(t *testing.T) {
"pre1", "pre1",
"step1", "step1",
"post1", "post1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
@@ -418,13 +421,37 @@ func TestNewJobExecutor(t *testing.T) {
"step3", "step3",
"post3", "post3",
"post2", "post2",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
hasError: false, hasError: false,
}, },
{
name: "jobOutputExpressionFailure",
steps: []*model.Step{{ID: "1"}},
preSteps: []bool{false},
postSteps: []bool{false},
executedSteps: []string{"startContainer", "step1", "interpolateOutputs", "stopContainer", "closeContainer"},
result: "failure",
output: "${{ 'test' != test }}",
},
{
name: "start failure",
steps: []*model.Step{{ID: "1"}},
executedSteps: []string{"startContainer", "closeContainer"},
startError: errors.New("start failed"),
},
{
name: "cancelled at startup boundary",
steps: []*model.Step{{ID: "1"}},
preSteps: []bool{false},
postSteps: []bool{true},
executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"},
result: "cancelled",
cancelOnStart: true,
},
} }
contains := func(needle string, haystack []string) bool { contains := func(needle string, haystack []string) bool {
@@ -435,7 +462,8 @@ func TestNewJobExecutor(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
ctx := common.WithJobErrorContainer(context.Background()) ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
defer cancel()
jim := &jobInfoMock{} jim := &jobInfoMock{}
sfm := &stepFactoryMock{} sfm := &stepFactoryMock{}
rc := &RunContext{ rc := &RunContext{
@@ -450,15 +478,22 @@ func TestNewJobExecutor(t *testing.T) {
}, },
Config: &Config{}, Config: &Config{},
} }
if tt.output != "" {
rc.Run.Job().Outputs = map[string]string{"bad": tt.output}
rc.outputTemplate = map[string]string{"bad": tt.output}
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
executorOrder := make([]string, 0) executorOrder := make([]string, 0)
jim.On("steps").Return(tt.steps) jim.On("steps").Return(tt.steps)
if len(tt.steps) > 0 { if len(tt.steps) > 0 {
jim.On("startContainer").Return(func(ctx context.Context) error { jim.On("startContainer").Return(func(_ context.Context) error {
executorOrder = append(executorOrder, "startContainer") executorOrder = append(executorOrder, "startContainer")
return nil if tt.cancelOnStart {
cancel()
}
return tt.startError
}) })
} }
@@ -492,23 +527,31 @@ func TestNewJobExecutor(t *testing.T) {
defer sm.AssertExpectations(t) defer sm.AssertExpectations(t)
} }
if len(tt.steps) > 0 { if len(tt.steps) > 0 && tt.startError == nil {
jim.On("matrix").Return(map[string]any{}) jim.On("matrix").Return(map[string]any{})
jim.On("interpolateOutputs").Return(func(ctx context.Context) error { jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "interpolateOutputs") executorOrder = append(executorOrder, "interpolateOutputs")
if tt.output != "" {
return rc.interpolateOutputs()(ctx)
}
return nil return nil
}) })
if contains("stopContainer", tt.executedSteps) { if contains("stopContainer", tt.executedSteps) {
jim.On("stopContainer").Return(func(ctx context.Context) error { jim.On("stopContainer").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "stopContainer") executorOrder = append(executorOrder, "stopContainer")
require.NoError(t, ctx.Err())
_, bounded := ctx.Deadline()
require.True(t, bounded)
return nil return nil
}) })
} }
jim.On("result", tt.result) jim.On("result", tt.result)
}
if len(tt.steps) > 0 {
jim.On("closeContainer").Return(func(ctx context.Context) error { jim.On("closeContainer").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "closeContainer") executorOrder = append(executorOrder, "closeContainer")
return nil return nil
@@ -517,7 +560,15 @@ func TestNewJobExecutor(t *testing.T) {
executor := newJobExecutor(jim, sfm, rc) executor := newJobExecutor(jim, sfm, rc)
err := executor(ctx) err := executor(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act switch {
case tt.startError != nil:
require.ErrorIs(t, err, tt.startError)
case tt.cancelOnStart:
require.ErrorIs(t, err, context.Canceled)
default:
require.NoError(t, err)
}
assert.Empty(t, rc.Run.Job().Outputs["bad"])
assert.Equal(t, tt.executedSteps, executorOrder) assert.Equal(t, tt.executedSteps, executorOrder)
jim.AssertExpectations(t) jim.AssertExpectations(t)
+6 -4
View File
@@ -51,10 +51,11 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
rawLogger.Infof("shell: %s", shell) rawLogger.Infof("shell: %s", shell)
} }
env := maps.Clone(rc.GetEnv()) env := map[string]string{}
if jobContainer := rc.Run.Job().Container(); jobContainer != nil { if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
maps.Copy(env, jobContainer.Env) maps.Copy(env, jobContainer.Env)
} }
maps.Copy(env, rc.GetEnv())
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env) rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
rc.ApplyExtraPath(ctx, &env) rc.ApplyExtraPath(ctx, &env)
@@ -95,10 +96,11 @@ func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]
} }
func (rc *RunContext) processHookFileCommands(ctx context.Context) error { func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil { err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnvFile)
return err if pathErr := rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand)); pathErr != nil && err == nil {
err = pathErr
} }
return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand)) return err
} }
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it // hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
+68 -42
View File
@@ -168,49 +168,77 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
type entryProcessor func(entry *logrus.Entry) *logrus.Entry type entryProcessor func(entry *logrus.Entry) *logrus.Entry
// secretValueEncoders are the shapes a secret takes on its way into a log: a base64
// payload, a JSON string, or a URL component. An action that serializes a secret leaks
// it in one of these forms, which a mask of the verbatim value alone does not catch, so
// every form is masked as well. This mirrors the value encoders of GitHub's runner.
var secretValueEncoders = []func(string) string{ var secretValueEncoders = []func(string) string{
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) }, func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
base64ShiftEncoder(1), base64ShiftEncoder(1),
base64ShiftEncoder(2), base64ShiftEncoder(2),
base64InteriorEncoder(0),
base64InteriorEncoder(1),
base64InteriorEncoder(2),
expressionStringEscape,
jsonStringEscape, jsonStringEscape,
jsonStringEscapeNoHTML, jsonStringEscapeNoHTML,
url.QueryEscape, uriDataEscape,
url.QueryEscape, // the form-encoded twin of uriDataEscape, which spells a space "+"
url.PathEscape, url.PathEscape,
xmlDataEscape,
trimDoubleQuotes,
} }
// minShiftedBase64Len is the shortest shifted base64 fragment worth masking. A shorter // base64ShiftEncoder reproduces the 3-byte alignments of `Basic base64("user:token")`, and
// one carries too few bytes of the secret to identify it and would mask unrelated output. // its padded tail only matches a secret that ends the payload.
const minShiftedBase64Len = 8
// base64ShiftEncoder returns the part of a secret's base64 form that survives when the
// secret does not start on a 3-byte boundary of the payload it is embedded in. base64
// encodes three bytes at a time, so `Authorization: Basic base64("user:token")` contains
// the base64 of the token alone only when the prefix length happens to be a multiple of
// three; at the other two alignments the encoding of the whole value differs. Encoding
// the secret behind shift filler bytes reproduces those alignments, which is what the
// Base64StringEscapeShift1/2 encoders of GitHub's runner do.
//
// The leading group (filler mixed with the secret's first bytes) and the trailing group
// (padded here, but continuing into whatever follows the secret) are dropped, leaving the
// group-aligned middle that does appear verbatim in the log.
func base64ShiftEncoder(shift int) func(string) string { func base64ShiftEncoder(shift int) func(string) string {
return func(v string) string {
value := []byte(v)
if len(value) > shift {
value = value[shift:]
}
return base64.StdEncoding.EncodeToString(value)
}
}
const minInteriorBase64Len = 8 // below this a fragment matches unrelated output
// base64InteriorEncoder keeps the aligned middle, so a secret with data after it still matches.
func base64InteriorEncoder(shift int) func(string) string {
return func(v string) string { return func(v string) string {
buf := make([]byte, shift+len(v)) buf := make([]byte, shift+len(v))
copy(buf[shift:], v) copy(buf[shift:], v)
encoded := base64.StdEncoding.EncodeToString(buf) encoded := base64.StdEncoding.EncodeToString(buf)
// Keep only the aligned middle, and only when enough of it is left to be a if len(encoded) < 8+minInteriorBase64Len {
// distinctive pattern rather than a fragment that matches unrelated output.
if len(encoded) < 8+minShiftedBase64Len {
return "" return ""
} }
return encoded[4 : len(encoded)-4] return encoded[4 : len(encoded)-4]
} }
} }
func expressionStringEscape(v string) string {
return strings.ReplaceAll(v, "'", "''")
}
func uriDataEscape(v string) string {
return strings.ReplaceAll(url.QueryEscape(v), "+", "%20")
}
var xmlDataEscaper = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
func xmlDataEscape(v string) string {
return xmlDataEscaper.Replace(v)
}
func trimDoubleQuotes(v string) string {
if len(v) > 8 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) {
return v[1 : len(v)-1]
}
return ""
}
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes, // jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder // which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes // escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
@@ -245,36 +273,34 @@ func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
return oldnew return oldnew
} }
// AppendSecretMasker registers v and each of its lines, as GitHub does.
func AppendSecretMasker(oldnew []string, v string) []string { func AppendSecretMasker(oldnew []string, v string) []string {
ret := oldnew ret := appendMaskedValue(oldnew, v)
for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
for l := range strings.SplitSeq(v, "\n") { ret = appendMaskedValue(ret, strings.TrimSpace(l))
tm := strings.TrimSpace(l)
// formatted JSON secrets could otherwise mask {,[,],} everywhere
if len(tm) > 1 {
ret = append(ret, tm, "***")
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
if strings.ContainsAny(tm, "%\r\n") {
ret = append(ret, EscapeCommandData(tm), "***")
}
}
} }
return ret
}
// The encoded forms are derived from the whole value: a multi-line secret is // appendMaskedValue registers one value and every shape it takes on its way into a log.
// encoded as one string, not line by line. func appendMaskedValue(ret []string, v string) []string {
trimmed := strings.TrimSpace(v) // formatted JSON secrets could otherwise mask {,[,],} everywhere
if len(trimmed) <= 1 { if len(strings.TrimSpace(v)) <= 1 || slices.Contains(ret, v) {
return ret return ret
} }
ret = append(ret, v, "***")
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
if strings.ContainsAny(v, "%\r\n") {
ret = append(ret, EscapeCommandData(v), "***")
}
for _, encode := range secretValueEncoders { for _, encode := range secretValueEncoders {
encoded := encode(trimmed) encoded := encode(v)
// An encoding that leaves the value unchanged is already masked above. // An encoding that leaves the value unchanged is already masked above.
if encoded == trimmed || len(encoded) <= 1 || slices.Contains(ret, encoded) { if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) {
continue continue
} }
ret = append(ret, encoded, "***") ret = append(ret, encoded, "***")
} }
return ret return ret
} }
+33 -29
View File
@@ -64,26 +64,27 @@ func TestValueMasker(t *testing.T) {
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a // A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
// URL — must be masked as well: masking only the verbatim value leaks it. // URL — must be masked as well: masking only the verbatim value leaks it.
func TestValueMaskerEncodedSecrets(t *testing.T) { func TestValueMaskerEncodedSecrets(t *testing.T) {
secret := `p@ss w"rd/1`
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
for _, tc := range []struct { for _, tc := range []struct {
name string name, secret string
line string encoded []string
}{ }{
{"verbatim", "the token is " + secret}, {"common encodings", `p@ss w"rd/1`, []string{
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))}, `p@ss w"rd/1`, base64.StdEncoding.EncodeToString([]byte(`p@ss w"rd/1`)),
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`}, jsonStringEscape(`p@ss w"rd/1`), url.PathEscape(`p@ss w"rd/1`),
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)}, }},
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"}, {"XML expression and quotes", `"a'b&c<d>"`, []string{
`&quot;a&apos;b&amp;c&lt;d&gt;&quot;`, `"a''b&c<d>"`, `a'b&c<d>`,
}},
{"URI spaces", "a b", []string{"a%20b", "a+b"}},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line}) masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": tc.secret}))
entry := masker(&logrus.Entry{Context: t.Context(), Message: strings.Join(tc.encoded, " ")})
assert.Contains(t, entry.Message, "***") assert.Contains(t, entry.Message, "***")
assert.NotContains(t, entry.Message, secret) for _, disallowed := range tc.encoded {
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret))) assert.NotContains(t, entry.Message, disallowed)
assert.NotContains(t, entry.Message, url.QueryEscape(secret)) }
}) })
} }
} }
@@ -124,15 +125,21 @@ func TestValueMaskerHidesExtraMasks(t *testing.T) {
// ::add-mask:: values go through the same masker, so they get the same treatment. // ::add-mask:: values go through the same masker, so they get the same treatment.
func TestValueMaskerEncodedMasks(t *testing.T) { func TestValueMaskerEncodedMasks(t *testing.T) {
masks := []string{"s3cr3t value"} masks := []string{"s3cr3t value", "first\rsecond", " s3cr3t "}
masker := valueMasker(false, AppendSecretMaskers(nil, nil)) masker := valueMasker(false, AppendSecretMaskers(nil, nil))
entry := masker(&logrus.Entry{ for _, tc := range []struct {
Context: WithMasks(t.Context(), &masks), line, want string
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")), }{
}) {"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")), "encoded: ***"},
{"first and second", "*** and ***"},
assert.Equal(t, "encoded: ***", entry.Message) {"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
} {
entry := masker(&logrus.Entry{Context: WithMasks(t.Context(), &masks), Message: tc.line})
assert.Equal(t, tc.want, entry.Message, tc.line)
}
} }
// A token in a Basic auth header is base64'd together with the user name, so the token's // A token in a Basic auth header is base64'd together with the user name, so the token's
@@ -140,7 +147,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks // alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
// the token to anyone who can decode the log. // the token to anyone who can decode the log.
func TestValueMaskerBase64Alignments(t *testing.T) { func TestValueMaskerBase64Alignments(t *testing.T) {
secret := "s3cr3t-token-value" secret := "s3cr3t"
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret})) masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
// One prefix per alignment: len%3 of 0, 1 and 2. // One prefix per alignment: len%3 of 0, 1 and 2.
@@ -150,8 +157,6 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded}) entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
assert.Contains(t, entry.Message, "***") assert.Contains(t, entry.Message, "***")
// The aligned middle of the secret must be gone, so the payload can no longer be
// decoded back into the token.
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message) assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ") decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
decoded, err := base64.StdEncoding.DecodeString(decodable) decoded, err := base64.StdEncoding.DecodeString(decodable)
@@ -190,15 +195,14 @@ func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
// JSON, query and path escaping all leave it unchanged. // JSON, query and path escaping all leave it unchanged.
pairs := AppendSecretMasker(nil, "plaintoken") pairs := AppendSecretMasker(nil, "plaintoken")
assert.Equal(t, []string{ assert.Equal(t, []string{
"plaintoken", "***", "plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***", "aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
// The two shifted alignments, each without its leading and trailing group.
"YWludG9r", "***",
"bGFpbnRv", "***",
}, pairs) }, pairs)
// Too short to mask. // Too short to mask.
assert.Empty(t, AppendSecretMasker(nil, "x")) assert.Empty(t, AppendSecretMasker(nil, "x"))
assert.Empty(t, AppendSecretMasker(nil, " \t"))
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
} }
func TestJobLogFormatterDecodesCommandData(t *testing.T) { func TestJobLogFormatterDecodesCommandData(t *testing.T) {
+24 -16
View File
@@ -28,8 +28,8 @@ import (
// of upload-artifact. // of upload-artifact.
// //
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1 // getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, which is what // reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, so a patched cache
// keeps the runner out of the artifact path: the results URL still points at Gitea. // client reaches the cache server by its own address rather than through the results origin.
// //
// Either of these landing upstream makes this file deletable: // Either of these landing upstream makes this file deletable:
// //
@@ -105,37 +105,45 @@ func actionScriptPaths(dir string, action *model.Action) []string {
return paths return paths
} }
// patchActions edits the toolkit in an action's bundles. The caller holds the action directory's // patchActions returns a restore: the tree is shared, so a job with patching off gets no edits.
// clone lock, which is what keeps another job's checkout from resetting them before the copy. func patchActions(ctx context.Context, scripts []string) func() {
func patchActions(ctx context.Context, scripts []string) { restore := map[string][]byte{}
for _, script := range scripts { for _, script := range scripts {
switch patched, err := patchBundle(script); { original, err := patchBundle(script)
case err != nil: if original != nil {
restore[script] = original // also when the write failed part way through
}
if err != nil {
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err) common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
case patched: }
common.Logger(ctx).Debugf("actions toolkit: patched %s", script) }
return func() {
for script, original := range restore {
if err := os.WriteFile(script, original, 0o644); err != nil { //nolint:gosec // as the checkout wrote it
common.Logger(ctx).Warnf("actions toolkit: %s left patched in the shared copy: %v", script, err)
}
} }
} }
} }
func patchBundle(script string) (bool, error) { // patchBundle returns the bytes it replaced, or nil when it left the bundle alone.
func patchBundle(script string) ([]byte, error) {
info, err := os.Stat(script) info, err := os.Stat(script)
if err != nil { if err != nil {
return false, err return nil, err
} }
if info.Size() > maxBundleSize { if info.Size() > maxBundleSize {
return false, nil return nil, nil
} }
data, err := os.ReadFile(script) data, err := os.ReadFile(script)
if err != nil { if err != nil {
return false, err return nil, err
} }
patched, ok := patchedBundle(data) patched, ok := patchedBundle(data)
if !ok { if !ok {
return false, nil return nil, nil
} }
// No atomic write needed: every prepare checks the action out and hard resets it. return data, os.WriteFile(script, patched, info.Mode().Perm())
return true, os.WriteFile(script, patched, info.Mode().Perm())
} }
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache // patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
+10 -7
View File
@@ -176,16 +176,16 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
func TestPatchBundleIsIdempotent(t *testing.T) { func TestPatchBundleIsIdempotent(t *testing.T) {
script := bundleFile(t, gateTSC) script := bundleFile(t, gateTSC)
done, err := patchBundle(script) original, err := patchBundle(script)
require.NoError(t, err) require.NoError(t, err)
require.True(t, done) require.NotNil(t, original)
patched, err := os.ReadFile(script) patched, err := os.ReadFile(script)
require.NoError(t, err) require.NoError(t, err)
require.True(t, gateOpened(string(patched))) require.True(t, gateOpened(string(patched)))
done, err = patchBundle(script) original, err = patchBundle(script)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, done, "a patched bundle is not patched again") assert.Nil(t, original, "a patched bundle is not patched again")
again, err := os.ReadFile(script) again, err := os.ReadFile(script)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, string(patched), string(again)) assert.Equal(t, string(patched), string(again))
@@ -194,9 +194,9 @@ func TestPatchBundleIsIdempotent(t *testing.T) {
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) { func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
script := bundleFile(t, `console.log("checkout")`) script := bundleFile(t, `console.log("checkout")`)
done, err := patchBundle(script) original, err := patchBundle(script)
require.NoError(t, err) require.NoError(t, err)
assert.False(t, done) assert.Nil(t, original)
body, err := os.ReadFile(script) body, err := os.ReadFile(script)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, `console.log("checkout")`, string(body)) assert.Equal(t, `console.log("checkout")`, string(body))
@@ -244,13 +244,16 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600)) require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
var copied string var copied string
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error { cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error {
body, err := os.ReadFile(script) body, err := os.ReadFile(script)
require.NoError(t, err) require.NoError(t, err)
copied = string(body) copied = string(body)
return nil return nil
}) })
require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir(), "sub", "/var/run/act/actions/repo/sub")) require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir(), "sub", "/var/run/act/actions/repo/sub"))
source, err := os.ReadFile(script)
require.NoError(t, err)
assert.Equal(t, gateTSC, string(source), "the shared copy is left as it was found")
return copied return copied
} }
+5 -2
View File
@@ -95,9 +95,12 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL // remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor { func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
cloneURL = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL) interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
if err != nil {
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
}
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{ return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
URL: cloneURL, URL: interpolatedURL,
Ref: ref, Ref: ref,
Dir: targetDirectory, Dir: targetDirectory,
Token: token, Token: token,
+218 -109
View File
@@ -16,6 +16,7 @@ import (
"fmt" "fmt"
"io" "io"
maps0 "maps" maps0 "maps"
"net"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -37,6 +38,8 @@ import (
"github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/mount"
"github.com/opencontainers/selinux/go-selinux" "github.com/opencontainers/selinux/go-selinux"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
) )
// RunContext contains info about current job // RunContext contains info about current job
@@ -65,7 +68,11 @@ type RunContext struct {
actionInputs map[string]any // inputs of the composite action this runs, nil for a job actionInputs map[string]any // inputs of the composite action this runs, nil for a job
Masks []string Masks []string
cleanUpJobContainer common.Executor cleanUpJobContainer common.Executor
caller *caller // job calling this RunContext (reusable workflows) caller *caller // job calling this RunContext (reusable workflows)
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md) // summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
// have already been created on the JobContainer. The runner sets up file-command files // have already been created on the JobContainer. The runner sets up file-command files
// via JobContainer.Copy at the start of every phase, which truncates them — fine for // via JobContainer.Copy at the start of every phase, which truncates them — fine for
@@ -88,7 +95,9 @@ type RunContext struct {
jobFailed bool jobFailed bool
// empty for a host-mode job, which starts no container // empty for a host-mode job, which starts no container
jobContainerID string jobContainerID string
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
jobNetworkName string jobNetworkName string
dockerProxy *container.DockerProxy
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out // stepEnv is a copy of the running step's environment, so that workflow commands parsed out
// of the container's output can be judged against it. Written by runStepExecutor and read on // of the container's output can be judged against it. Written by runStepExecutor and read on
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr. // the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
@@ -243,11 +252,31 @@ func (rc *RunContext) validVolumes() []string {
if rc.Config.SharedToolCache { if rc.Config.SharedToolCache {
volumes = append(volumes, sharedToolCacheVolume) volumes = append(volumes, sharedToolCacheVolume)
} }
if rc.Config.BindWorkdir {
volumes = append(volumes, rc.Config.Workdir)
}
if rc.dockerProxy != nil {
volumes = append(volumes, rc.dockerProxy.Socket)
}
// TODO: add a new configuration to control whether the docker daemon can be mounted // TODO: add a new configuration to control whether the docker daemon can be mounted
return append(volumes, name, name+"-env", return append(volumes, name, name+"-env",
getDockerDaemonSocketMountPath(rc.containerDaemonSocket())) getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
} }
func (rc *RunContext) jobDockerSocket() string {
if rc.dockerProxy != nil {
return rc.dockerProxy.Socket
}
return getDockerDaemonSocketMountPath(rc.containerDaemonSocket())
}
func (rc *RunContext) startDockerProxy(ctx context.Context) {
if !filepath.IsAbs(strings.TrimPrefix(rc.containerDaemonSocket(), "unix://")) || common.Dryrun(ctx) {
return
}
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
}
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE. // toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
func (rc *RunContext) toolCache(fallback string) string { func (rc *RunContext) toolCache(fallback string) string {
if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" { if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" {
@@ -296,7 +325,7 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
} }
// Returns the binds and mounts for the container, resolving paths as appopriate // Returns the binds and mounts for the container, resolving paths as appopriate
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
name := rc.jobContainerName() name := rc.jobContainerName()
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
@@ -305,7 +334,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
if container := job.Container(); container != nil { if container := job.Container(); container != nil {
for _, v := range container.Volumes { for _, v := range container.Volumes {
if rc.ExprEval != nil { if rc.ExprEval != nil {
v = rc.ExprEval.Interpolate(context.Background(), v) var err error
if v, err = rc.ExprEval.Interpolate(context.Background(), v); err != nil {
return nil, nil, fmt.Errorf("unable to interpolate container.volumes: %w", err)
}
} }
volumes = append(volumes, v) volumes = append(volumes, v)
} }
@@ -314,8 +346,8 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
// the runner's own mounts below yield to the targets the job claims // the runner's own mounts below yield to the targets the job claims
binds, mounts, claimed := splitVolumes(volumes) binds, mounts, claimed := splitVolumes(volumes)
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] { if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock") binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
} }
if rc.Config.SharedToolCache { if rc.Config.SharedToolCache {
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] { if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
@@ -339,7 +371,7 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
} }
} }
return binds, mounts return binds, mounts, nil
} }
func (rc *RunContext) startHostEnvironment() common.Executor { func (rc *RunContext) startHostEnvironment() common.Executor {
@@ -430,7 +462,7 @@ var newContainer = container.NewContainer
func (rc *RunContext) startJobContainer() common.Executor { func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
image := rc.platformImage(ctx) image := rc.platformImage
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
username, password, err := rc.handleCredentials(ctx) username, password, err := rc.handleCredentials(ctx)
@@ -448,19 +480,20 @@ func (rc *RunContext) startJobContainer() common.Executor {
envList = append(envList, rc.runnerEnv(ctx)...) envList = append(envList, rc.runnerEnv(ctx)...)
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
ext := container.LinuxContainerEnvironmentExtensions{}
binds, mounts := rc.GetBindsAndMounts()
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>) // specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
// if using service containers, will create a new network for the containers. // if using service containers, will create a new network for the containers.
// and it will be removed after at last. // and it will be removed after at last.
networkName, createAndDeleteNetwork := rc.networkNameForGitea() networkName, createAndDeleteNetwork := rc.networkNameForGitea()
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
// add service containers // add service containers
for serviceID, spec := range rc.Run.Job().Services { for serviceID, spec := range rc.Run.Job().Services {
// GitHub compatibility: skip services whose image evaluates to an // GitHub compatibility: skip services whose image evaluates to an
// empty string, enabling conditional services via expressions // empty string, enabling conditional services via expressions
serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image) serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image)
if err != nil {
return fmt.Errorf("unable to interpolate service %s image: %w", serviceID, err)
}
if serviceImage == "" { if serviceImage == "" {
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID) logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue continue
@@ -470,16 +503,20 @@ func (rc *RunContext) startJobContainer() common.Executor {
// a service reaches the internet the way the job does; its own env still wins // a service reaches the internet the way the job does; its own env still wins
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv) maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
for k, v := range spec.Env { for k, v := range spec.Env {
interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v) if interpolatedEnvs[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate service %s env %s: %w", serviceID, k, err)
}
} }
envs := make([]string, 0, len(interpolatedEnvs)) envs := make([]string, 0, len(interpolatedEnvs))
for k, v := range interpolatedEnvs { for k, v := range interpolatedEnvs {
envs = append(envs, fmt.Sprintf("%s=%s", k, v)) envs = append(envs, fmt.Sprintf("%s=%s", k, v))
} }
// interpolate cmd // interpolate cmd
interpolatedCmd := make([]string, 0, len(spec.Cmd)) interpolatedCmd := make([]string, len(spec.Cmd))
for _, v := range spec.Cmd { for i, v := range spec.Cmd {
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v)) if interpolatedCmd[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate service %s command: %w", serviceID, err)
}
} }
// keep these local: reusing username/password would overwrite the // keep these local: reusing username/password would overwrite the
// credentials the job container is pulled with further down // credentials the job container is pulled with further down
@@ -488,25 +525,32 @@ func (rc *RunContext) startJobContainer() common.Executor {
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err) return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
} }
interpolatedVolumes := make([]string, 0, len(spec.Volumes)) interpolatedVolumes := make([]string, len(spec.Volumes))
for _, volume := range spec.Volumes { for i, volume := range spec.Volumes {
interpolatedVolumes = append(interpolatedVolumes, rc.ExprEval.Interpolate(ctx, volume)) if interpolatedVolumes[i], err = rc.ExprEval.Interpolate(ctx, volume); err != nil {
return fmt.Errorf("unable to interpolate service %s volumes: %w", serviceID, err)
}
} }
serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes) serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes)
interpolatedPorts := make([]string, 0, len(spec.Ports)) interpolatedPorts := make([]string, len(spec.Ports))
for _, port := range spec.Ports { for i, port := range spec.Ports {
interpolatedPorts = append(interpolatedPorts, rc.ExprEval.Interpolate(ctx, port)) if interpolatedPorts[i], err = rc.ExprEval.Interpolate(ctx, port); err != nil {
return fmt.Errorf("unable to interpolate service %s ports: %w", serviceID, err)
}
} }
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts) exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err) return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
} }
serviceOptions, err := rc.ExprEval.Interpolate(ctx, spec.Options)
if err != nil {
return fmt.Errorf("unable to interpolate service %s options: %w", serviceID, err)
}
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID) serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := newContainer(&container.NewContainerInput{ c := newContainer(&container.NewContainerInput{
Name: serviceContainerName, Name: serviceContainerName,
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: serviceImage, Image: serviceImage,
Username: serviceUsername, Username: serviceUsername,
Password: servicePassword, Password: servicePassword,
@@ -520,7 +564,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
WorkflowOptions: rc.ExprEval.Interpolate(ctx, spec.Options), WorkflowOptions: serviceOptions,
NetworkMode: networkName, NetworkMode: networkName,
NetworkAliases: []string{serviceID}, NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
@@ -531,12 +575,15 @@ func (rc *RunContext) startJobContainer() common.Executor {
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c}) rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c})
} }
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
// For Gitea, `jobContainerNetwork` should be the same as `networkName` // For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName jobContainerNetwork := networkName
rc.JobContainer = newContainer(&container.NewContainerInput{ workflowOptions, err := rc.workflowOptions(ctx)
if err != nil {
return err
}
ext := container.LinuxContainerEnvironmentExtensions{}
containerInput := &container.NewContainerInput{
Cmd: nil, Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir), WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
@@ -545,32 +592,39 @@ func (rc *RunContext) startJobContainer() common.Executor {
Password: password, Password: password,
Name: name, Name: name,
Env: envList, Env: envList,
Mounts: mounts,
NetworkMode: jobContainerNetwork, NetworkMode: jobContainerNetwork,
NetworkAliases: []string{rc.Name}, NetworkAliases: []string{rc.Name},
Binds: binds,
Stdout: logWriter, Stdout: logWriter,
Stderr: logWriter, Stderr: logWriter,
Privileged: rc.Config.Privileged, Privileged: rc.Config.Privileged,
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
RunnerOptions: rc.Config.ContainerOptions, RunnerOptions: rc.Config.ContainerOptions,
WorkflowOptions: rc.workflowOptions(ctx), WorkflowOptions: workflowOptions,
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
}) }
rc.JobContainer = newContainer(containerInput)
if rc.JobContainer == nil { if rc.JobContainer == nil {
return errors.New("failed to create job container") return errors.New("failed to create job container")
} }
defer printStartJobContainerGroup(ctx, image, name, networkName)()
if err := common.NewPipelineExecutor(
rc.stopJobContainer(),
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
).Finally(rc.closeContainer())(ctx); err != nil {
return err
}
rc.startDockerProxy(ctx)
if containerInput.Binds, containerInput.Mounts, err = rc.GetBindsAndMounts(); err != nil {
return err
}
containerInput.ValidVolumes = rc.validVolumes()
rc.jobNetworkName = networkName rc.jobNetworkName = networkName
defer printStartJobContainerGroup(ctx, image, name, networkName)()
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
rc.stopJobContainer(),
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions). container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
IfBool(createAndDeleteNetwork), IfBool(createAndDeleteNetwork),
rc.startServiceContainers(), rc.startServiceContainers(),
@@ -600,41 +654,46 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
}) })
} }
// cleanupJobResources removes everything the job created, continuing past failures.
// Only job container and volume errors are returned, the rest are logged.
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor { func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
removeJobContainer := rc.JobContainer != nil errs := []error{rc.closeDockerProxy(ctx)}
if rc.JobContainer != nil {
var errs []error
if removeJobContainer {
errs = append(errs, rc.JobContainer.Remove()(ctx)) errs = append(errs, rc.JobContainer.Remove()(ctx))
} }
if len(rc.serviceContainers) > 0 { if len(rc.serviceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName) logger.Infof("Cleaning up services for job %s", rc.JobName)
if err := rc.stopServiceContainers()(ctx); err != nil { errs = append(errs, rc.stopServiceContainers()(ctx))
logger.Errorf("Error while cleaning services: %v", err)
}
} }
if removeJobContainer { if !common.Dryrun(ctx) {
// after the containers using them, services can hold these via `--volumes-from` errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
}
if rc.JobContainer != nil {
name := rc.jobContainerName() name := rc.jobContainerName()
errs = append(errs, errs = append(errs,
container.NewDockerVolumeRemoveExecutor(name, false)(ctx), container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx)) container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
} }
if createAndDeleteNetwork { if createAndDeleteNetwork {
// last, once every container has detached
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName) logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil { errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
logger.Errorf("Error while cleaning network: %v", err)
}
} }
return errors.Join(errs...) return errors.Join(errs...)
} }
} }
func (rc *RunContext) closeDockerProxy(ctx context.Context) error {
if rc.dockerProxy == nil {
return nil
}
err := rc.dockerProxy.Close(ctx)
rc.dockerProxy = nil
if err != nil {
return fmt.Errorf("close docker proxy: %w", err)
}
return nil
}
func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) { func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) {
if len(rc.ExtraPath) > 0 { if len(rc.ExtraPath) > 0 {
path := rc.JobContainer.GetPathVariableName() path := rc.JobContainer.GetPathVariableName()
@@ -679,17 +738,21 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return err return err
} }
s := bufio.NewScanner(reader) decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
s := bufio.NewScanner(decoded)
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for s.Scan() { for s.Scan() {
line := s.Text() line := s.Text()
if len(line) > 0 { if len(line) > 0 {
rc.addPath(ctx, line) rc.addPath(ctx, line)
} }
} }
if err := s.Err(); err != nil {
return fmt.Errorf("reading path file: %w", err)
}
return nil return nil
} }
// stopJobContainer removes the job container (if it exists) and its volume (if it exists)
func (rc *RunContext) stopJobContainer() common.Executor { func (rc *RunContext) stopJobContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if rc.cleanUpJobContainer != nil { if rc.cleanUpJobContainer != nil {
@@ -726,10 +789,17 @@ func (rc *RunContext) startServiceContainers() common.Executor {
func (rc *RunContext) stopServiceContainers() common.Executor { func (rc *RunContext) stopServiceContainers() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
execs := []common.Executor{} execs := []common.Executor{}
for _, svc := range rc.serviceContainers { errs := make([]error, len(rc.serviceContainers))
execs = append(execs, svc.container.Remove().Finally(svc.container.Close())) for index, svc := range rc.serviceContainers {
execs = append(execs, func(ctx context.Context) error {
if err := errors.Join(svc.container.Remove()(ctx), svc.container.Close()(ctx)); err != nil {
errs[index] = fmt.Errorf("clean service %s: %w", svc.name, err)
}
return nil
})
} }
return common.NewParallelExecutor(len(execs), execs...)(ctx) errs = append(errs, common.NewParallelExecutor(len(execs), execs...)(ctx))
return errors.Join(errs...)
} }
} }
@@ -875,6 +945,9 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
return nil return nil
} }
rc.jobContainerID = info.ID rc.jobContainerID = info.ID
if source := info.Mounts[rc.githubWorkspace()]; source != "" {
rc.Env["GITEA_DOCKER_WORKSPACE"] = source
}
return nil return nil
} }
} }
@@ -917,18 +990,31 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites // pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
// with its own resolved values (last wins, as on GitHub) instead of the first combo's // with its own resolved values (last wins, as on GitHub) instead of the first combo's
// resolved values freezing the shared template against later combos. // resolved values freezing the shared template against later combos.
defer lockJob(job)() // Resolved up front so one failure publishes none of them, as GitHub does.
outputs := make(map[string]string, len(rc.outputTemplate))
var err error
for k, v := range rc.outputTemplate { for k, v := range rc.outputTemplate {
job.Outputs[k] = ee.Interpolate(ctx, v) if outputs[k], err = ee.Interpolate(ctx, v); err != nil {
err = fmt.Errorf("failed to evaluate job output %q: %w", k, err)
break
}
} }
return nil defer lockJob(job)()
for k := range rc.outputTemplate {
if err != nil {
job.Outputs[k] = ""
continue
}
job.Outputs[k] = outputs[k]
}
return err
} }
} }
func (rc *RunContext) startContainer() common.Executor { func (rc *RunContext) startContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
var err error var err error
if rc.IsHostEnv(ctx) { if rc.IsHostEnv() {
err = rc.startHostEnvironment()(ctx) err = rc.startHostEnvironment()(ctx)
} else { } else {
err = rc.startJobContainer()(ctx) err = rc.startJobContainer()(ctx)
@@ -943,25 +1029,19 @@ func (rc *RunContext) startContainer() common.Executor {
} }
func (rc *RunContext) cleanupFailedStart(ctx context.Context) { func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
if rc.cleanUpJobContainer == nil { cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
return defer cancel()
cleanup := rc.cleanUpJobContainer
if cleanup == nil {
cleanup = rc.closeDockerProxy
} }
cleanCtx := ctx if err := cleanup(cleanCtx); err != nil {
if ctx.Err() != nil {
// the start likely failed because ctx was cancelled, detach so teardown still runs
var cancel context.CancelFunc
cleanCtx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
defer cancel()
}
if err := rc.cleanUpJobContainer(cleanCtx); err != nil {
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err) common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
} }
} }
func (rc *RunContext) IsHostEnv(ctx context.Context) bool { func (rc *RunContext) IsHostEnv() bool {
platform := rc.runsOnImage(ctx) return strings.EqualFold(rc.platformImage, "-self-hosted")
image := rc.containerImage(ctx)
return image == "" && strings.EqualFold(platform, "-self-hosted")
} }
func (rc *RunContext) stopContainer() common.Executor { func (rc *RunContext) stopContainer() common.Executor {
@@ -1046,32 +1126,35 @@ func (rc *RunContext) Executor() (common.Executor, error) {
}, nil }, nil
} }
func (rc *RunContext) containerImage(ctx context.Context) string { func (rc *RunContext) containerImage(ctx context.Context) (string, error) {
job := rc.Run.Job() c := rc.Run.Job().Container()
if c == nil {
c := job.Container() return "", nil
if c != nil {
return rc.ExprEval.Interpolate(ctx, c.Image)
} }
image, err := rc.ExprEval.Interpolate(ctx, c.Image)
return "" if err != nil {
return "", fmt.Errorf("unable to interpolate container.image: %w", err)
}
return image, nil
} }
func (rc *RunContext) runsOnImage(ctx context.Context) string { func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
if rc.Run.Job().RunsOn() == nil { if rc.Run.Job().RunsOn() == nil {
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String()) common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
} }
job := rc.Run.Job() runsOn := rc.Run.Job().RunsOn()
runsOn := job.RunsOn()
for i, v := range runsOn { for i, v := range runsOn {
runsOn[i] = rc.ExprEval.Interpolate(ctx, v) var err error
if runsOn[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return "", fmt.Errorf("unable to interpolate runs-on: %w", err)
}
} }
if rc.Config.PlatformPicker != nil { if rc.Config.PlatformPicker != nil {
return rc.Config.PlatformPicker(runsOn) return rc.Config.PlatformPicker(runsOn), nil
} }
return "" return "", nil
} }
func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string { func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
@@ -1092,21 +1175,26 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
return model.RunsOnFromNode(rawRunsOn) return model.RunsOnFromNode(rawRunsOn)
} }
func (rc *RunContext) platformImage(ctx context.Context) string { // resolvePlatformImage evaluates the job's image once, so every consumer sees the image the job started with.
if containerImage := rc.containerImage(ctx); containerImage != "" { func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
return containerImage image, err := rc.containerImage(ctx)
if err == nil && image == "" {
image, err = rc.runsOnImage(ctx)
} }
rc.platformImage = image
return rc.runsOnImage(ctx) return err
} }
func (rc *RunContext) workflowOptions(ctx context.Context) string { func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) {
c := rc.Run.Job().Container() c := rc.Run.Job().Container()
if c == nil { if c == nil {
return "" return "", nil
} }
options, err := rc.ExprEval.Interpolate(ctx, c.Options)
return rc.ExprEval.Interpolate(ctx, c.Options) if err != nil {
return "", fmt.Errorf("unable to interpolate container.options: %w", err)
}
return options, nil
} }
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) { func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
@@ -1136,8 +1224,10 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
return true, nil return true, nil
} }
img := rc.platformImage(ctx) if err := rc.resolvePlatformImage(ctx); err != nil {
if img == "" { return false, err
}
if rc.platformImage == "" {
for _, platformName := range rc.runsOnPlatformNames(ctx) { for _, platformName := range rc.runsOnPlatformNames(ctx) {
l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName) l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
} }
@@ -1245,12 +1335,23 @@ func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
} }
runnerContext["name"] = rc.Config.RunnerName runnerContext["name"] = rc.Config.RunnerName
runnerContext["environment"] = "self-hosted" runnerContext["environment"] = "self-hosted"
runnerContext["workspace"] = parentDir(rc.githubWorkspace())
if rc.Config.RunnerDebug() { if rc.Config.RunnerDebug() {
runnerContext["debug"] = "1" runnerContext["debug"] = "1"
} }
return runnerContext return runnerContext
} }
func (rc *RunContext) githubWorkspace() string {
if rc.JobContainer != nil {
return rc.JobContainer.ToContainerPath(rc.Config.Workdir)
}
if workspace := rc.Config.Env["GITHUB_WORKSPACE"]; workspace != "" {
return workspace
}
return rc.Config.Workdir
}
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext { func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
logger := common.Logger(ctx) logger := common.Logger(ctx)
ghc := &model.GithubContext{ ghc := &model.GithubContext{
@@ -1277,11 +1378,10 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
RefType: rc.Config.Env["GITHUB_REF_TYPE"], RefType: rc.Config.Env["GITHUB_REF_TYPE"],
BaseRef: rc.Config.Env["GITHUB_BASE_REF"], BaseRef: rc.Config.Env["GITHUB_BASE_REF"],
HeadRef: rc.Config.Env["GITHUB_HEAD_REF"], HeadRef: rc.Config.Env["GITHUB_HEAD_REF"],
Workspace: rc.Config.Env["GITHUB_WORKSPACE"], Workspace: rc.githubWorkspace(),
} }
if rc.JobContainer != nil { if rc.JobContainer != nil {
ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json" ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json"
ghc.Workspace = rc.JobContainer.ToContainerPath(rc.Config.Workdir)
} }
if ghc.RunID == "" { if ghc.RunID == "" {
@@ -1514,7 +1614,7 @@ func (rc *RunContext) imageOS(ctx context.Context) string {
// log that runs-on is missing. // log that runs-on is missing.
return "" return ""
} }
if imageOS := imageOSFromImage(rc.platformImage(ctx)); imageOS != "" { if imageOS := imageOSFromImage(rc.platformImage); imageOS != "" {
return imageOS return imageOS
} }
@@ -1547,7 +1647,7 @@ func imageOSFromImage(image string) string {
func setActionRuntimeVars(rc *RunContext, env map[string]string) { func setActionRuntimeVars(rc *RunContext, env map[string]string) {
actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL") actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL")
if actionsRuntimeURL == "" { if actionsRuntimeURL == "" {
actionsRuntimeURL = fmt.Sprintf("http://%s:%s/", rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort) actionsRuntimeURL = "http://" + net.JoinHostPort(rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort) + "/"
} }
env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL
@@ -1576,14 +1676,23 @@ func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials ma
} }
ee := rc.NewExpressionEvaluator(ctx) ee := rc.NewExpressionEvaluator(ctx)
username := ee.Interpolate(ctx, credentials["username"]) interpolate := func(key string) (string, error) {
if username == "" { value, err := ee.Interpolate(ctx, credentials[key])
return "", "", errors.New("failed to interpolate " + prefix + "credentials.username") if err != nil {
return "", fmt.Errorf("failed to interpolate %scredentials.%s: %w", prefix, key, err)
}
if value == "" {
return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
}
return value, nil
} }
password := ee.Interpolate(ctx, credentials["password"]) username, err := interpolate("username")
if password == "" { if err != nil {
return "", "", errors.New("failed to interpolate " + prefix + "credentials.password") return "", "", err
}
password, err := interpolate("password")
if err != nil {
return "", "", err
} }
return username, password, nil return username, password, nil
} }
+141 -21
View File
@@ -9,6 +9,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http"
"net/http/httptest"
"os" "os"
"runtime" "runtime"
"strings" "strings"
@@ -220,8 +223,9 @@ func (fakeContainer) Start(bool) common.Executor { return func(context.Context)
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } } func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } } func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) GetActPath() string { return "/var/run/act" } func (fakeContainer) GetActPath() string { return "/var/run/act" }
func (fakeContainer) ToContainerPath(path string) string { return path }
func (fakeContainer) Create([]string, []string) common.Executor { func (fakeContainer) Create([]string, []string) common.Executor {
return func(context.Context) error { return nil } return func(context.Context) error { return nil }
} }
@@ -266,10 +270,28 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
}, },
} }
rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
require.NoError(t, rc.resolvePlatformImage(t.Context()))
// the inputs are built before the missing daemon fails the first call daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock") w.Header().Set("Content-Type", "application/json")
require.Error(t, rc.startJobContainer()(t.Context())) switch {
case strings.HasSuffix(r.URL.Path, "/_ping"):
w.Header().Set("API-Version", "1.47")
_, _ = io.WriteString(w, "OK")
case strings.HasSuffix(r.URL.Path, "/containers/json"), strings.HasSuffix(r.URL.Path, "/networks"):
_, _ = io.WriteString(w, "[]")
case strings.HasSuffix(r.URL.Path, "/volumes"):
_, _ = io.WriteString(w, `{"Volumes":[]}`)
case strings.HasSuffix(r.URL.Path, "/info"):
_, _ = io.WriteString(w, `{"Architecture":"amd64","OSType":"linux"}`)
default:
t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL)
http.NotFound(w, r)
}
}))
t.Cleanup(daemon.Close)
t.Setenv("DOCKER_HOST", daemon.URL)
require.NoError(t, rc.startJobContainer()(t.Context()))
return inputs return inputs
} }
@@ -326,6 +348,7 @@ jobs:
require.Equal(t, []string{"data"}, redis.ValidVolumes) require.Equal(t, []string{"data"}, redis.ValidVolumes)
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts) require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
require.Empty(t, redis.Binds) // the docker socket is the job container's alone require.Empty(t, redis.Binds) // the docker socket is the job container's alone
require.Empty(t, redis.WorkingDir)
} }
// Only the workflow's options may be stripped later, so the two sources have to reach the // Only the workflow's options may be stripped later, so the two sources have to reach the
@@ -449,7 +472,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
config := testcase.rc.Config config := testcase.rc.Config
config.Workdir = testcase.name config.Workdir = testcase.name
config.BindWorkdir = bindWorkDir config.BindWorkdir = bindWorkDir
gotbind, gotmount := rctemplate.GetBindsAndMounts() gotbind, gotmount, err := rctemplate.GetBindsAndMounts()
require.NoError(t, err)
// Name binds/mounts are either/or // Name binds/mounts are either/or
if config.BindWorkdir { if config.BindWorkdir {
@@ -508,7 +532,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
rc.ExprEval = rc.NewExpressionEvaluator(context.Background()) rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
gotbind, gotmount := rc.GetBindsAndMounts() gotbind, gotmount, err := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro") assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
assert.NotContains(t, gotbind, "${{ secrets.MAME }}") assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
assert.NotContains(t, gotmount, "${{ secrets.MAME }}") assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
@@ -537,7 +562,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.Run.JobID = "job1" rc.Run.JobID = "job1"
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
gotbind, gotmount := rc.GetBindsAndMounts() gotbind, gotmount, err := rc.GetBindsAndMounts()
require.NoError(t, err)
if len(testcase.wantbind) > 0 { if len(testcase.wantbind) > 0 {
assert.Contains(t, gotbind, testcase.wantbind) assert.Contains(t, gotbind, testcase.wantbind)
@@ -572,11 +598,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
Config: &Config{}, Config: &Config{},
} }
_, gotmount := rc.GetBindsAndMounts() _, gotmount, err := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.NotContains(t, gotmount, sharedToolCacheVolume) assert.NotContains(t, gotmount, sharedToolCacheVolume)
rc.Config.SharedToolCache = true rc.Config.SharedToolCache = true
_, gotmount = rc.GetBindsAndMounts() _, gotmount, err = rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
}) })
} }
@@ -602,6 +630,11 @@ func TestRunContextValidVolumes(t *testing.T) {
// a job may mount it only while the runner does // a job may mount it only while the runner does
rc.Config.SharedToolCache = false rc.Config.SharedToolCache = false
assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume) assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume)
rc.Config.Workdir = "/workspace/1/owner/repo"
assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir)
rc.Config.BindWorkdir = true
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
} }
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) { func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
@@ -614,7 +647,7 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
serviceContainers: []*serviceContainer{{name: "svc", container: service}}, serviceContainers: []*serviceContainer{{name: "svc", container: service}},
} }
err := rc.cleanupJobResources("external-network", false)(context.Background()) err := rc.cleanupJobResources("external-network", false)(common.WithDryrun(t.Context(), true))
require.NoError(t, err) require.NoError(t, err)
service.AssertExpectations(t) service.AssertExpectations(t)
} }
@@ -623,11 +656,12 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) { func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock") t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
removeError, closeError := errors.New("remove service"), errors.New("close service")
jobContainer := &containerMock{} jobContainer := &containerMock{}
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once() jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
service := &containerMock{} service := &containerMock{}
service.On("Remove").Return(func(context.Context) error { return nil }).Once() service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
service.On("Close").Return(func(context.Context) error { return nil }).Once() service.On("Close").Return(func(context.Context) error { return closeError }).Once()
rc := &RunContext{ rc := &RunContext{
Name: "job", Name: "job",
@@ -639,7 +673,11 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx)) err := rc.cleanupJobResources("job-network", true)(ctx)
require.ErrorContains(t, err, "removal failed")
require.ErrorIs(t, err, removeError)
require.ErrorIs(t, err, closeError)
require.ErrorIs(t, err, context.Canceled)
jobContainer.AssertExpectations(t) jobContainer.AssertExpectations(t)
service.AssertExpectations(t) service.AssertExpectations(t)
} }
@@ -653,8 +691,10 @@ func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
r := &runnerImpl{config: &Config{}} r := &runnerImpl{config: &Config{}}
ctx := context.Background() ctx := context.Background()
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"}) rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"})
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"}) require.NoError(t, err)
rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"})
require.NoError(t, err)
require.NoError(t, rcA.interpolateOutputs()(ctx)) require.NoError(t, rcA.interpolateOutputs()(ctx))
require.NoError(t, rcB.interpolateOutputs()(ctx)) require.NoError(t, rcB.interpolateOutputs()(ctx))
@@ -1054,12 +1094,19 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
calls int calls int
err error err error
sentinel any sentinel any
cancel context.CancelFunc
} }
newRC := func(c *capture) *RunContext { newRC := func(c *capture) *RunContext {
return &RunContext{ return &RunContext{
JobName: "job", JobName: "job",
cleanUpJobContainer: func(ctx context.Context) error { cleanUpJobContainer: func(ctx context.Context) error {
c.calls++ c.calls++
if c.cancel != nil {
c.cancel()
}
deadline, ok := ctx.Deadline()
require.True(t, ok)
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, time.Second)
c.err = ctx.Err() c.err = ctx.Err()
c.sentinel = ctx.Value(sentinel) c.sentinel = ctx.Value(sentinel)
return nil return nil
@@ -1067,9 +1114,10 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
} }
} }
t.Run("runs teardown on the live context", func(t *testing.T) { t.Run("detaches teardown from cancellation during cleanup", func(t *testing.T) {
var c capture ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
ctx := context.WithValue(context.Background(), sentinel, "v") defer cancel()
c := capture{cancel: cancel}
newRC(&c).cleanupFailedStart(ctx) newRC(&c).cleanupFailedStart(ctx)
@@ -1087,7 +1135,7 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
assert.Equal(t, 1, c.calls) assert.Equal(t, 1, c.calls)
require.NoError(t, c.err) require.NoError(t, c.err)
assert.Nil(t, c.sentinel) assert.Equal(t, "v", c.sentinel)
}) })
t.Run("no-op when there is nothing to clean up", func(t *testing.T) { t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
@@ -1279,6 +1327,29 @@ func TestGetJobContextReportsContainers(t *testing.T) {
}, jobContext.Services) }, jobContext.Services)
} }
func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
job := &containerMock{}
job.On("Inspect", mock.Anything).Return(&container.Info{
ID: "job-container-id",
Mounts: map[string]string{"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data"},
}, nil)
rc := &RunContext{
Config: &Config{Workdir: "/workspace/owner/repo/"},
Env: map[string]string{},
JobContainer: job,
}
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
assert.Equal(t, "job-container-id", rc.jobContainerID)
assert.Equal(t, "/var/lib/docker/volumes/job/_data", rc.Env["GITEA_DOCKER_WORKSPACE"])
rc.Config.Workdir = "/elsewhere"
rc.Env = map[string]string{}
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
assert.NotContains(t, rc.Env, "GITEA_DOCKER_WORKSPACE")
}
// A job that never started a container reports an empty context, not a placeholder. // A job that never started a container reports an empty context, not a placeholder.
func TestGetJobContextWithoutContainer(t *testing.T) { func TestGetJobContextWithoutContainer(t *testing.T) {
jobContext := (&RunContext{}).getJobContext() jobContext := (&RunContext{}).getJobContext()
@@ -1327,12 +1398,14 @@ func TestRunContextImageOS(t *testing.T) {
t.Run("prefers the release in the resolved image tag", func(t *testing.T) { t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest") rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" } rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" }
require.NoError(t, rc.resolvePlatformImage(ctx))
assert.Equal(t, "ubuntu24", rc.imageOS(ctx)) assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
}) })
t.Run("falls back to the runs-on label", func(t *testing.T) { t.Run("falls back to the runs-on label", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-22.04") rc := createRunsOnRunContext(t, "ubuntu-22.04")
rc.Config.PlatformPicker = func([]string) string { return "some-image" } rc.Config.PlatformPicker = func([]string) string { return "some-image" }
require.NoError(t, rc.resolvePlatformImage(ctx))
assert.Equal(t, "ubuntu22", rc.imageOS(ctx)) assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
}) })
@@ -1352,10 +1425,15 @@ func TestRunContextGetRunnerContext(t *testing.T) {
t.Run("adds the runner values the container cannot know", func(t *testing.T) { t.Run("adds the runner values the container cannot know", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest") rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.RunnerName = "runner-1" rc.Config.RunnerName = "runner-1"
rc.Config.Workdir = "/workspace/owner/repo"
runnerContext := rc.getRunnerContext(ctx) runnerContext := rc.getRunnerContext(ctx)
assert.Equal(t, "runner-1", runnerContext["name"]) assert.Equal(t, "runner-1", runnerContext["name"])
assert.Equal(t, "self-hosted", runnerContext["environment"]) assert.Equal(t, "self-hosted", runnerContext["environment"])
assert.Equal(t, "/workspace/owner", runnerContext["workspace"])
assert.Equal(t, "/workspace/owner/repo", rc.getGithubContext(ctx).Workspace)
rc.Config.Env = map[string]string{"GITHUB_WORKSPACE": "/configured/work"}
assert.Equal(t, "/configured/work", rc.getGithubContext(ctx).Workspace)
assert.NotContains(t, runnerContext, "debug") assert.NotContains(t, runnerContext, "debug")
}) })
@@ -1368,15 +1446,52 @@ func TestRunContextGetRunnerContext(t *testing.T) {
t.Run("keeps the execution environment values", func(t *testing.T) { t.Run("keeps the execution environment values", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest") rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.JobContainer = &container.HostEnvironment{TmpDir: "/tmp/act", ToolCache: "/tmp/tool_cache"} rc.Config.Workdir = "/host/work/owner/repo"
rc.JobContainer = &container.HostEnvironment{Workdir: rc.Config.Workdir, Path: "/container/work/owner/repo", TmpDir: "/tmp/act", ToolCache: "/tmp/tool_cache"}
runnerContext := rc.getRunnerContext(ctx) runnerContext := rc.getRunnerContext(ctx)
assert.Equal(t, "/tmp/act", runnerContext["temp"]) assert.Equal(t, "/tmp/act", runnerContext["temp"])
assert.Equal(t, "/tmp/tool_cache", runnerContext["tool_cache"]) assert.Equal(t, "/tmp/tool_cache", runnerContext["tool_cache"])
assert.Equal(t, "/container/work/owner", runnerContext["workspace"])
assert.Equal(t, "/container/work/owner/repo", rc.getGithubContext(ctx).Workspace)
assert.NotEmpty(t, runnerContext["os"]) assert.NotEmpty(t, runnerContext["os"])
}) })
} }
func TestRunContextUpdateExtraPath(t *testing.T) {
longPath := strings.Repeat("x", 64*1024+1)
for _, testcase := range []struct {
name string
content []byte
want string
wantErr bool
}{
{name: "UTF-8 BOM", content: []byte("\xef\xbb\xbf/utf8/tools\n"), want: "/utf8/tools"},
{name: "UTF-16 BOM", content: []byte{0xff, 0xfe, 'C', 0, ':', 0, '\\', 0, 't', 0, 'o', 0, 'o', 0, 'l', 0, 's', 0, '\n', 0}, want: `C:\tools`},
{name: "over 64 KiB", content: []byte(longPath + "\n"), want: longPath},
{name: "over 16 MiB", content: []byte(strings.Repeat("x", 16*1024*1024+1)), wantErr: true},
} {
t.Run(testcase.name, func(t *testing.T) {
logger := log.New()
logger.SetOutput(io.Discard)
ctx := common.WithLogger(t.Context(), logger)
jobContainer := &containerMock{}
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
Return(io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "path", body: string(testcase.content)}))), nil).Once()
defer jobContainer.AssertExpectations(t)
rc := &RunContext{JobContainer: jobContainer}
err := rc.UpdateExtraPath(ctx, "/github/path")
if testcase.wantErr {
require.ErrorContains(t, err, "reading path file")
return
}
require.NoError(t, err)
assert.Equal(t, []string{testcase.want}, rc.ExtraPath)
})
}
}
func TestParentDir(t *testing.T) { func TestParentDir(t *testing.T) {
assert.Empty(t, parentDir("")) assert.Empty(t, parentDir(""))
assert.Empty(t, parentDir("repo")) assert.Empty(t, parentDir("repo"))
@@ -1390,6 +1505,10 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest") rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.RunnerName = "runner-1" rc.Config.RunnerName = "runner-1"
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"} rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
t.Setenv("ACTIONS_RUNTIME_URL", "")
rc.Config.ArtifactServerPath = "artifacts"
rc.Config.ArtifactServerAddr = "2001:db8::1"
rc.Config.ArtifactServerPort = "8080"
env := map[string]string{} env := map[string]string{}
rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env) rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env)
@@ -1398,4 +1517,5 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"]) assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"])
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"]) assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
assert.Equal(t, "1", env["RUNNER_DEBUG"]) assert.Equal(t, "1", env["RUNNER_DEBUG"])
assert.Equal(t, "http://[2001:db8::1]:8080/", env["ACTIONS_RUNTIME_URL"])
} }
+14 -5
View File
@@ -198,7 +198,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString) log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix) log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
strategyRc := runner.newRunContext(ctx, run, nil) strategyRc, err := runner.newRunContext(ctx, run, nil)
if err != nil {
return err
}
// Resolve template expressions in the matrix node before Matrix() is called. // Resolve template expressions in the matrix node before Matrix() is called.
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback. // On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil { if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
@@ -230,7 +233,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes)) log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
for i, matrix := range matrixes { for i, matrix := range matrixes {
rc := runner.newRunContext(ctx, run, matrix) rc, err := runner.newRunContext(ctx, run, matrix)
if err != nil {
return err
}
rc.JobName = rc.Name rc.JobName = rc.Name
if len(matrixes) > 1 { if len(matrixes) > 1 {
rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1) rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1)
@@ -315,7 +321,7 @@ func handleFailure(plan *model.Plan) common.Executor {
} }
} }
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) *RunContext { func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) {
rc := &RunContext{ rc := &RunContext{
Config: runner.config, Config: runner.config,
Run: run, Run: run,
@@ -324,15 +330,18 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
Matrix: matrix, Matrix: matrix,
caller: runner.caller, caller: runner.caller,
} }
if err := rc.resolveWorkflowCall(ctx); err != nil {
return nil, err
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
rc.Name = rc.maskSecrets(rc.ExprEval.Interpolate(ctx, run.String())) rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String()))
// Snapshot the job's pristine output expressions now, before any matrix combo runs and // Snapshot the job's pristine output expressions now, before any matrix combo runs and
// rewrites the shared Job.Outputs (see interpolateOutputs). // rewrites the shared Job.Outputs (see interpolateOutputs).
if job := run.Job(); job != nil { if job := run.Job(); job != nil {
rc.outputTemplate = maps.Clone(job.Outputs) rc.outputTemplate = maps.Clone(job.Outputs)
} }
return rc return rc, nil
} }
// For Gitea // For Gitea
+45 -42
View File
@@ -11,6 +11,7 @@ import (
"path" "path"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -58,13 +59,10 @@ func (s stepStage) String() string {
func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error { func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error {
env := map[string]string{} env := map[string]string{}
err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx) err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx)
if err != nil {
return err
}
for k, v := range env { for k, v := range env {
setter(ctx, map[string]string{"name": k}, v) setter(ctx, map[string]string{"name": k}, v)
} }
return nil return err
} }
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor { func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
@@ -85,23 +83,26 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
rc.StepResults[rc.CurrentStep] = stepResult rc.StepResults[rc.CurrentStep] = stepResult
} }
setupEnv(ctx, step) err := setupEnv(ctx, step)
var runStep bool
runStep, err := isStepEnabled(ctx, ifExpression, step, stage) if err == nil {
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
}
if err != nil { if err != nil {
stepResult.Conclusion = model.StepStatusFailure stepResult.Conclusion = model.StepStatusFailure
stepResult.Outcome = model.StepStatusFailure stepResult.Outcome = model.StepStatusFailure
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepModel)
return err return err
} }
if !runStep { if !runStep {
stepResult.Conclusion = model.StepStatusSkipped stepResult.Conclusion = model.StepStatusSkipped
stepResult.Outcome = model.StepStatusSkipped stepResult.Outcome = model.StepStatusSkipped
logger.WithField("stepResult", stepResult.Outcome).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression) logger.WithField("stepResult", stepResult.Conclusion).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
return nil return nil
} }
stepString := rc.ExprEval.Interpolate(ctx, stepModel.String()) stepString := rc.ExprEval.InterpolateName(ctx, stepModel.String())
if strings.Contains(stepString, "::add-mask::") { if strings.Contains(stepString, "::add-mask::") {
stepString = "add-mask command" stepString = "add-mask command"
} }
@@ -157,11 +158,11 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if topRC.summaryFileInitialized == nil { if topRC.summaryFileInitialized == nil {
topRC.summaryFileInitialized = map[int]bool{} topRC.summaryFileInitialized = map[int]bool{}
} }
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
if !topRC.summaryFileInitialized[stepSummaryIndex] { if !topRC.summaryFileInitialized[stepSummaryIndex] {
files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666}) _ = rc.JobContainer.Copy(actPath, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})(ctx)
topRC.summaryFileInitialized[stepSummaryIndex] = true topRC.summaryFileInitialized[stepSummaryIndex] = true
} }
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS. // The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
// Cloned: the step executor keeps writing to its own env map after this point, on a // Cloned: the step executor keeps writing to its own env map after this point, on a
@@ -179,15 +180,29 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if err == nil { if err == nil {
err = insecureErr err = insecureErr
} }
if fileErr := processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
err = fileErr
}
if fileErr := processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
err = fileErr
}
if fileErr := processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
err = fileErr
}
if fileErr := rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
err = fileErr
}
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
if err == nil { if err == nil {
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString) logger.WithField("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString)
} else { } else {
stepResult.Outcome = model.StepStatusFailure stepResult.Outcome = model.StepStatusFailure
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage) continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
if parseErr != nil { if parseErr != nil {
stepResult.Conclusion = model.StepStatusFailure stepResult.Conclusion = model.StepStatusFailure
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
return parseErr return parseErr
} }
@@ -202,64 +217,51 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
// Infof: Errorf entries are promoted to the user log by the reporter, // Infof: Errorf entries are promoted to the user log by the reporter,
// which would duplicate the ##[error] annotation emitted elsewhere. // which would duplicate the ##[error] annotation emitted elsewhere.
logger.WithField("stepResult", stepResult.Outcome).Infof("Failure - %s %s", stage, stepString) logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
}
// Process Runner File Commands
orgerr := err
err = processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnv)
if err != nil {
return err
}
err = processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState)
if err != nil {
return err
}
err = processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput)
if err != nil {
return err
}
err = rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand))
if err != nil {
return err
}
if orgerr != nil {
return orgerr
} }
return err return err
} }
} }
func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) { func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) {
timeout := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes) timeout, err := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
if timeout != "" { if err != nil {
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil { common.Logger(ctx).Errorf("An error occurred when attempting to determine the step timeout: %s", err)
} else if timeout != "" {
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeOutMinutes > 0 {
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute) return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
} }
} }
return ctx, func() {} return ctx, func() {}
} }
func setupEnv(ctx context.Context, step step) { func setupEnv(ctx context.Context, step step) error {
rc := step.getRunContext() rc := step.getRunContext()
mergeEnv(ctx, step) mergeEnv(ctx, step)
// merge step env last, since it should not be overwritten // merge step env last, since it should not be overwritten
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv()) mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
var err error
exprEval := rc.NewExpressionEvaluator(ctx) exprEval := rc.NewExpressionEvaluator(ctx)
for k, v := range *step.getEnv() { for k, v := range *step.getEnv() {
if !strings.HasPrefix(k, "INPUT_") { if !strings.HasPrefix(k, "INPUT_") {
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v) if (*step.getEnv())[k], err = exprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
}
} }
} }
// after we have an evaluated step context, update the expressions evaluator with a new env context // after we have an evaluated step context, update the expressions evaluator with a new env context
// you can use step level env in the with property of a uses construct // you can use step level env in the with property of a uses construct
exprEval = rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) inputEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) })
for k, v := range *step.getEnv() { for k, v := range *step.getEnv() {
if strings.HasPrefix(k, "INPUT_") { if strings.HasPrefix(k, "INPUT_") {
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v) if (*step.getEnv())[k], err = inputEval().Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
}
} }
} }
return nil
} }
func mergeEnv(ctx context.Context, step step) { func mergeEnv(ctx context.Context, step step) {
@@ -269,7 +271,8 @@ func mergeEnv(ctx context.Context, step step) {
c := job.Container() c := job.Container()
if c != nil { if c != nil {
mergeIntoMap(step, env, rc.GetEnv(), c.Env) // container env is the image's baseline, which job env and $GITHUB_ENV override
mergeIntoMap(step, env, c.Env, rc.GetEnv())
} else { } else {
mergeIntoMap(step, env, rc.GetEnv()) mergeIntoMap(step, env, rc.GetEnv())
} }
+8 -4
View File
@@ -122,15 +122,19 @@ func (sal *stepActionLocal) getActionModel() *model.Action {
return sal.action return sal.action
} }
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) *RunContext { func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
if sal.compositeRunContext == nil { if sal.compositeRunContext == nil {
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses) actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext) _, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
sal.compositeRunContext = newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir) compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
sal.compositeSteps = sal.compositeRunContext.compositeExecutor(sal.action) if err != nil {
return nil, err
}
sal.compositeRunContext = compositeRunContext
sal.compositeSteps = compositeRunContext.compositeExecutor(sal.action)
} }
return sal.compositeRunContext return sal.compositeRunContext, nil
} }
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps { func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
+2 -1
View File
@@ -254,7 +254,8 @@ func TestStepActionLocalPost(t *testing.T) {
if tt.mocks.exec { if tt.mocks.exec {
suffixMatcher := func(suffix string) any { suffixMatcher := func(suffix string) any {
return mock.MatchedBy(func(array []string) bool { return mock.MatchedBy(func(array []string) bool {
return strings.HasSuffix(array[1], suffix) return len(array) == 3 && array[0] == "node" && array[1] == "--preserve-symlinks-main" &&
strings.HasSuffix(array[2], suffix)
}) })
} }
cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err }) cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err })
+24 -10
View File
@@ -51,7 +51,11 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
// Since actions can specify the download source via a url prefix. // Since actions can specify the download source via a url prefix.
// The prefix may contain some sensitive information that needs to be stored in secrets, // The prefix may contain some sensitive information that needs to be stored in secrets,
// so we need to interpolate the expression value for uses first. // so we need to interpolate the expression value for uses first.
sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses) uses, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
if err != nil {
return fmt.Errorf("unable to interpolate uses: %w", err)
}
sar.Step.Uses = uses
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) { if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) {
@@ -160,9 +164,12 @@ func (sar *stepActionRemote) main() common.Executor {
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace") common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
return nil return nil
} }
eval := sar.RunContext.NewExpressionEvaluator(ctx) checkoutPath, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.With["path"])
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), eval.Interpolate(ctx, sar.Step.With["path"])) if err != nil {
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx) return fmt.Errorf("unable to interpolate with.path: %w", err)
}
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath)
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
} }
return sar.runAction(sar, sar.actionDir(), sar.remoteAction)(ctx) return sar.runAction(sar, sar.actionDir(), sar.remoteAction)(ctx)
@@ -229,14 +236,18 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
return sar.action return sar.action
} }
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext { func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
if sar.compositeRunContext == nil { if sar.compositeRunContext == nil {
actionDir := sar.actionDir() actionDir := sar.actionDir()
actionLocation := path.Join(actionDir, sar.remoteAction.Path) actionLocation := path.Join(actionDir, sar.remoteAction.Path)
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext) _, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
sar.compositeRunContext = newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir) compositeRunContext, err := newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
sar.compositeSteps = sar.compositeRunContext.compositeExecutor(sar.action) if err != nil {
return nil, err
}
sar.compositeRunContext = compositeRunContext
sar.compositeSteps = compositeRunContext.compositeExecutor(sar.action)
} else { } else {
// Re-evaluate environment here. For remote actions the environment // Re-evaluate environment here. For remote actions the environment
// need to be re-created for every stage (pre, main, post) as there // need to be re-created for every stage (pre, main, post) as there
@@ -244,11 +255,14 @@ func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunCon
// stages are executed. (e.g. the output of another action is the // stages are executed. (e.g. the output of another action is the
// input for this action during the main stage, but the env // input for this action during the main stage, but the env
// was already created during the pre stage) // was already created during the pre stage)
env := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar) env, err := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar)
sar.compositeRunContext.setActionEnv(env) if err != nil {
return nil, err
}
sar.compositeRunContext.setCompositeActionEnv(env)
sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath
} }
return sar.compositeRunContext return sar.compositeRunContext, nil
} }
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps { func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
+20 -2
View File
@@ -242,6 +242,24 @@ func TestStepActionRemote(t *testing.T) {
cm.AssertExpectations(t) cm.AssertExpectations(t)
}) })
} }
t.Run("refreshes composite inputs without leaking environment", func(t *testing.T) {
step := &stepActionRemote{
Step: &model.Step{Uses: "org/composite@v1", With: map[string]string{"SHARED": "first"}},
RunContext: &RunContext{Config: &Config{ActionCacheDir: t.TempDir()}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, JobContainer: &jobContainerMock{}},
action: &model.Action{Inputs: map[string]model.Input{"shared": {}}},
env: map[string]string{},
remoteAction: newRemoteAction("org/composite@v1"),
}
for _, value := range []string{"first", "second"} {
step.env["INPUT_SHARED"] = value
composite, err := step.getCompositeRunContext(t.Context())
require.NoError(t, err)
assert.Equal(t, map[string]any{"shared": value}, composite.actionInputs)
assert.NotContains(t, composite.Env, "INPUT_SHARED")
}
})
} }
func TestStepActionRemotePrepare(t *testing.T) { func TestStepActionRemotePrepare(t *testing.T) {
@@ -453,10 +471,10 @@ func TestStepActionRemotePost(t *testing.T) {
if tt.mocks.exec { if tt.mocks.exec {
// Use mock.MatchedBy to match the exec command with hash-based path // Use mock.MatchedBy to match the exec command with hash-based path
execMatcher := mock.MatchedBy(func(args []string) bool { execMatcher := mock.MatchedBy(func(args []string) bool {
if len(args) != 2 { if len(args) != 3 {
return false return false
} }
return args[0] == "node" && strings.Contains(args[1], "post.js") return args[0] == "node" && args[1] == "--preserve-symlinks-main" && strings.Contains(args[2], "post.js")
}) })
cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err }) cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err })
+15 -3
View File
@@ -6,6 +6,7 @@ package runner
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -58,17 +59,28 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
image := strings.TrimPrefix(step.Uses, "docker://") image := strings.TrimPrefix(step.Uses, "docker://")
eval := rc.NewExpressionEvaluator(ctx) eval := rc.NewExpressionEvaluator(ctx)
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.With["args"])) args, err := eval.Interpolate(ctx, step.With["args"])
if err != nil {
return fmt.Errorf("unable to interpolate with.args: %w", err)
}
cmd, err := shellquote.Split(args)
if err != nil { if err != nil {
return err return err
} }
var entrypoint []string var entrypoint []string
if entry := eval.Interpolate(ctx, step.With["entrypoint"]); entry != "" { entry, err := eval.Interpolate(ctx, step.With["entrypoint"])
if err != nil {
return fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
}
if entry != "" {
entrypoint = []string{entry} entrypoint = []string{entry}
} }
stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "") stepContainer, err := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
if err != nil {
return err
}
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
stepContainer.Pull(rc.Config.ForcePull), stepContainer.Pull(rc.Config.ForcePull),
+5 -4
View File
@@ -16,6 +16,7 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
) )
func TestStepDockerMain(t *testing.T) { func TestStepDockerMain(t *testing.T) {
@@ -144,7 +145,7 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
} }
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
_ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "") _, _ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
assert.Equal(t, tc.allocPTY, captured.AllocatePTY) assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
}) })
} }
@@ -210,10 +211,10 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
} }
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx), require.NoError(t, sd.RunContext.resolvePlatformImage(ctx))
"IsHostEnv mismatch for platform %q", tc.platform) assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform)
_ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "") _, _ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
if tc.expectDefault { if tc.expectDefault {
assert.Equal(t, "default", captured.NetworkMode, assert.Equal(t, "default", captured.NetworkMode,
+71 -33
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"maps" "maps"
"runtime" "runtime"
"slices" "slices"
@@ -22,6 +23,8 @@ import (
yaml "go.yaml.in/yaml/v4" yaml "go.yaml.in/yaml/v4"
) )
var builtinShells = []string{"bash", "sh", "pwsh", "powershell", "cmd", "python"}
type stepRun struct { type stepRun struct {
Step *model.Step Step *model.Step
RunContext *RunContext RunContext *RunContext
@@ -251,15 +254,35 @@ func getScriptName(rc *RunContext, step *model.Step) string {
// OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown // OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown
func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, err error) { func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, err error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
sr.setupShell(ctx) eval := sr.RunContext.NewStepExpressionEvaluator(ctx, sr)
sr.setupWorkingDirectory(ctx) implicitShell, err := sr.setupShell(ctx, eval)
if err != nil {
return "", "", err
}
if err := sr.setupWorkingDirectory(ctx, eval); err != nil {
return "", "", err
}
step := sr.Step step := sr.Step
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run) script, err = eval.Interpolate(ctx, step.Run)
if err != nil {
return "", "", fmt.Errorf("unable to interpolate the run script: %w", err)
}
sr.interpolatedScript = script sr.interpolatedScript = script
// GitHub matches the built-in names case-insensitively, so `shell: PWSH` is valid
if slices.Contains(builtinShells, strings.ToLower(step.Shell)) {
step.Shell = strings.ToLower(step.Shell)
}
scCmd := step.ShellCommand() scCmd := step.ShellCommand()
if implicitShell && (step.Shell == "bash" || step.Shell == "sh") {
scCmd = step.Shell + " -e {0}"
}
if !strings.Contains(scCmd, "{0}") {
return "", "", fmt.Errorf("invalid shell option %q: format must contain {0}", step.Shell)
}
sr.shellCommand = scCmd sr.shellCommand = scCmd
name = getScriptName(sr.RunContext, step) name = getScriptName(sr.RunContext, step)
@@ -268,7 +291,8 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27 // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27
runPrepend := "" runPrepend := ""
runAppend := "" runAppend := ""
switch step.Shell { shellCommand, _, _ := strings.Cut(step.Shell, " ")
switch shellCommand {
case "bash", "sh": case "bash", "sh":
name += ".sh" name += ".sh"
case "pwsh", "powershell": case "pwsh", "powershell":
@@ -292,64 +316,78 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
rc := sr.getRunContext() rc := sr.getRunContext()
scriptPath := fmt.Sprintf("%s/%s", rc.JobContainer.GetActPath(), name) scriptPath := fmt.Sprintf("%s/%s", rc.JobContainer.GetActPath(), name)
sr.cmdline = strings.Replace(scCmd, `{0}`, scriptPath, 1) sr.cmdline = strings.ReplaceAll(scCmd, `{0}`, scriptPath)
sr.cmd, err = shellquote.Split(sr.cmdline) sr.cmd, err = shellquote.Split(sr.cmdline)
return name, script, err return name, script, err
} }
func (sr *stepRun) setupShell(ctx context.Context) { func (sr *stepRun) setupShell(ctx context.Context, eval *expressionEvaluator) (bool, error) {
rc := sr.RunContext rc := sr.RunContext
step := sr.Step step := sr.Step
if step.Shell == "" { shell, err := eval.Interpolate(ctx, step.Shell)
step.Shell = rc.Run.Job().Defaults.Run.Shell if err != nil {
return false, fmt.Errorf("unable to interpolate the shell: %w", err)
} }
if shell == "" {
step.Shell = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, step.Shell) shell = rc.jobRunDefaults.Shell
if step.Shell == "" {
step.Shell = rc.Run.Workflow.Defaults.Run.Shell
} }
if shell == "" {
shell = rc.Run.Workflow.Defaults.Run.Shell
}
step.Shell = shell
if step.Shell == "" { implicitShell := step.Shell == ""
if implicitShell {
shellWithFallback := []string{"bash", "sh"}
env := maps.Clone(sr.env)
rc.ApplyExtraPath(ctx, &env)
if _, ok := rc.JobContainer.(*container.HostEnvironment); ok { if _, ok := rc.JobContainer.(*container.HostEnvironment); ok {
shellWithFallback := []string{"bash", "sh"}
// Don't use bash on windows by default, if not using a docker container // Don't use bash on windows by default, if not using a docker container
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
shellWithFallback = []string{"pwsh", "powershell"} shellWithFallback = []string{"pwsh", "powershell"}
} }
step.Shell = shellWithFallback[0] step.Shell = shellWithFallback[0]
env := maps.Clone(sr.env)
sr.getRunContext().ApplyExtraPath(ctx, &env)
_, err := lookpath.LookPath2(shellWithFallback[0], env) _, err := lookpath.LookPath2(shellWithFallback[0], env)
if err != nil { if err != nil {
step.Shell = shellWithFallback[1] step.Shell = shellWithFallback[1]
} }
} else if containerImage := rc.containerImage(ctx); containerImage != "" { } else {
// Currently only linux containers are supported, use sh by default like actions/runner step.Shell = shellWithFallback[0]
step.Shell = "sh" if !rc.containerHasBash(ctx, env) {
step.Shell = shellWithFallback[1]
}
} }
} }
return implicitShell, nil
} }
func (sr *stepRun) setupWorkingDirectory(ctx context.Context) { // containerHasBash probes once per job, else every implicit-shell step pays for an exec.
rc := sr.RunContext func (rc *RunContext) containerHasBash(ctx context.Context, env map[string]string) bool {
step := sr.Step top := rc.topLevelRunContext()
var workingdirectory string if top.hasBash == nil {
stdout, stderr := rc.JobContainer.ReplaceLogWriter(io.Discard, io.Discard)
if step.WorkingDirectory == "" { found := rc.JobContainer.Exec([]string{"sh", "-c", "command -v bash >/dev/null 2>&1"}, env, "", "")(ctx) == nil
workingdirectory = rc.Run.Job().Defaults.Run.WorkingDirectory rc.JobContainer.ReplaceLogWriter(stdout, stderr)
} else { top.hasBash = &found
workingdirectory = step.WorkingDirectory
} }
return *top.hasBash
}
// jobs can receive context values, so we interpolate func (sr *stepRun) setupWorkingDirectory(ctx context.Context, eval *expressionEvaluator) error {
workingdirectory = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, workingdirectory) rc := sr.RunContext
workingdirectory, err := eval.Interpolate(ctx, sr.Step.WorkingDirectory)
// but top level keys in workflow file like `defaults` or `env` can't if err != nil {
return fmt.Errorf("unable to interpolate the working directory: %w", err)
}
if workingdirectory == "" {
workingdirectory = rc.jobRunDefaults.WorkingDirectory
}
// top level keys in workflow file like `defaults` or `env` can't hold expressions
if workingdirectory == "" { if workingdirectory == "" {
workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory
} }
sr.WorkingDirectory = workingdirectory sr.WorkingDirectory = workingdirectory
return nil
} }
+127 -8
View File
@@ -6,8 +6,12 @@ package runner
import ( import (
"bytes" "bytes"
"cmp"
"context" "context"
"io" "io"
"os"
"path/filepath"
"runtime"
"testing" "testing"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
@@ -15,8 +19,13 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
) )
type shellContainerMock struct{ *containerMock }
func (*shellContainerMock) ReplaceLogWriter(_, _ io.Writer) (io.Writer, io.Writer) { return nil, nil }
func TestStepRun(t *testing.T) { func TestStepRun(t *testing.T) {
cm := &containerMock{} cm := &containerMock{}
fileEntry := &container.FileEntry{ fileEntry := &container.FileEntry{
@@ -34,17 +43,12 @@ func TestStepRun(t *testing.T) {
JobID: "1", JobID: "1",
Workflow: &model.Workflow{ Workflow: &model.Workflow{
Jobs: map[string]*model.Job{ Jobs: map[string]*model.Job{
"1": { "1": {},
Defaults: model.Defaults{
Run: model.RunDefaults{
Shell: "bash",
},
},
},
}, },
}, },
}, },
JobContainer: cm, JobContainer: cm,
jobRunDefaults: model.RunDefaults{Shell: "bash"},
}, },
Step: &model.Step{ Step: &model.Step{
ID: "1", ID: "1",
@@ -73,3 +77,118 @@ func TestStepRun(t *testing.T) {
cm.AssertExpectations(t) cm.AssertExpectations(t)
} }
func TestStepRunShellParity(t *testing.T) {
tests := []struct {
name, run, shell, workingDir string
env map[string]string
host bool
probeErr error
wantExt string
wantCmd []string
wantErr string
}{
{
name: "implicit host bash",
host: true,
wantCmd: []string{"bash", "-e", "/var/run/act/workflow/1.sh"},
},
{
name: "implicit container bash",
wantCmd: []string{"bash", "-e", "/var/run/act/workflow/1.sh"},
},
{
name: "implicit container sh fallback",
probeErr: assert.AnError,
wantCmd: []string{"sh", "-e", "/var/run/act/workflow/1.sh"},
},
{
name: "custom pwsh template",
shell: "pwsh -NoProfile -File {0}",
wantExt: ".ps1",
wantCmd: []string{"pwsh", "-NoProfile", "-File", "/var/run/act/workflow/1.ps1"},
},
{
name: "missing placeholder",
shell: "bash -e",
wantErr: `invalid shell option "bash -e": format must contain {0}`,
},
{
name: "all placeholders",
shell: "bash -c '. {0}; . {0}'",
wantCmd: []string{"bash", "-c", ". /var/run/act/workflow/1.sh; . /var/run/act/workflow/1.sh"},
},
{
name: "step env expressions",
shell: "${{ env.SHELL }}",
workingDir: "${{ env.DIR }}",
env: map[string]string{"SHELL": "python {0}", "DIR": "subdir"},
wantExt: ".py",
wantCmd: []string{"python", "/var/run/act/workflow/1.py"},
},
{
name: "run expression without a context",
run: "echo ${{ COMMIT_SHA }}",
wantErr: "unable to interpolate the run script:",
},
{
name: "shell expression without a context",
shell: "${{ SHELL }}",
wantErr: "unable to interpolate the shell:",
},
{
name: "working directory expression without a context",
workingDir: "${{ DIR }}",
wantErr: "unable to interpolate the working directory:",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cm := &containerMock{}
var jobContainer container.ExecutionsEnvironment = &shellContainerMock{cm}
if test.host {
if runtime.GOOS == "windows" {
t.Skip("Linux host shell selection")
}
test.env = map[string]string{"PATH": t.TempDir()}
require.NoError(t, os.WriteFile(filepath.Join(test.env["PATH"], "bash"), nil, 0o755))
jobContainer = &container.HostEnvironment{ActPath: "/var/run/act"}
} else if test.shell == "" {
cm.On("Exec", []string{"sh", "-c", "command -v bash >/dev/null 2>&1"},
mock.AnythingOfType("map[string]string"), "", "").Return(func(context.Context) error {
return test.probeErr
})
}
sr := &stepRun{
RunContext: &RunContext{
Config: &Config{},
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
JobContainer: jobContainer,
},
Step: &model.Step{ID: "1", Run: cmp.Or(test.run, "echo hi"), Shell: test.shell, WorkingDirectory: test.workingDir},
env: test.env,
}
name, script, err := sr.setupShellCommand(t.Context())
if test.wantErr != "" {
require.ErrorContains(t, err, test.wantErr)
return
}
require.NoError(t, err)
if test.wantExt == "" {
test.wantExt = ".sh"
}
wantScript := "\necho hi\n"
if test.wantExt == ".ps1" {
wantScript = "$ErrorActionPreference = 'stop'\necho hi\nif ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE }"
}
assert.Equal(t, "workflow/1"+test.wantExt, name)
assert.Equal(t, wantScript, script)
assert.Equal(t, test.wantCmd, sr.cmd)
assert.Equal(t, test.env["DIR"], sr.WorkingDirectory)
cm.AssertExpectations(t)
})
}
}
+93 -1
View File
@@ -7,12 +7,15 @@ package runner
import ( import (
"context" "context"
"errors" "errors"
"os"
"testing" "testing"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -157,7 +160,7 @@ func TestSetupEnv(t *testing.T) {
sm.On("getStepModel").Return(step) sm.On("getStepModel").Return(step)
sm.On("getEnv").Return(&env) sm.On("getEnv").Return(&env)
setupEnv(context.Background(), sm) require.NoError(t, setupEnv(context.Background(), sm))
// These are commit or system specific // These are commit or system specific
delete(env, "GITHUB_REF") delete(env, "GITHUB_REF")
@@ -404,3 +407,92 @@ func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx) errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx)
require.NoError(t, errB) require.NoError(t, errB)
} }
func TestRunStepExecutorParity(t *testing.T) {
newStep := func(t *testing.T, stepModel *model.Step) *stepRun {
rc := createRunContext(t)
rc.JobContainer = &container.HostEnvironment{ActPath: t.TempDir()}
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
return &stepRun{RunContext: rc, Step: stepModel, env: map[string]string{}}
}
badExpression := "${{ 'test' != test }}"
for _, test := range []struct {
name, wantError string
step *model.Step
executor common.Executor
}{
{"condition error", "if-expression", &model.Step{ID: "condition", If: yaml.Node{Value: badExpression}}, noopExecutor},
{"continue-on-error expression error", "continue-on-error expression", &model.Step{ID: "continue", RawContinueOnError: badExpression}, common.NewErrorExecutor(assert.AnError)},
} {
t.Run(test.name, func(t *testing.T) {
step := newStep(t, test.step)
logger, hook := logrustest.NewNullLogger()
err := runStepExecutor(step, stepStageMain, test.executor)(common.WithLogger(context.Background(), logger))
require.ErrorContains(t, err, test.wantError)
assert.Equal(t, model.StepStatusFailure, step.RunContext.StepResults[test.step.ID].Conclusion)
assert.Equal(t, model.StepStatusFailure, hook.LastEntry().Data["stepResult"])
})
}
t.Run("file command error honors continue-on-error", func(t *testing.T) {
step := newStep(t, &model.Step{ID: "commands", RawContinueOnError: "true"})
logger, hook := logrustest.NewNullLogger()
err := runStepExecutor(step, stepStageMain, func(context.Context) error {
require.NoError(t, os.WriteFile(step.env["GITHUB_ENV"], []byte("GOOD=1\nmalformed\n"), 0o600))
require.NoError(t, os.WriteFile(step.env["GITHUB_OUTPUT"], []byte("kept=value\n"), 0o600))
require.NoError(t, os.WriteFile(step.env["GITHUB_STATE"], []byte("saved=value\n"), 0o600))
return nil
})(common.WithLogger(context.Background(), logger))
require.NoError(t, err)
result := step.RunContext.StepResults[step.Step.ID]
assert.Equal(t, model.StepStatusFailure, result.Outcome)
assert.Equal(t, model.StepStatusSuccess, result.Conclusion)
assert.Equal(t, model.StepStatusSuccess, hook.LastEntry().Data["stepResult"])
assert.Equal(t, "1", step.RunContext.Env["GOOD"])
assert.Equal(t, "value", result.Outputs["kept"])
assert.Equal(t, "value", step.RunContext.IntraActionState[step.Step.ID]["saved"])
for _, name := range []string{"GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_STATE", "GITHUB_PATH"} {
contents, readErr := os.ReadFile(step.env[name])
require.NoError(t, readErr)
assert.Empty(t, contents)
}
})
t.Run("composite child files do not leak", func(t *testing.T) {
outer := newStep(t, &model.Step{ID: "outer"})
childRC := &RunContext{
Config: outer.RunContext.Config, Run: outer.RunContext.Run, Env: map[string]string{}, StepResults: map[string]*model.StepResult{},
JobContainer: outer.RunContext.JobContainer, Parent: outer.RunContext,
}
childRC.ExprEval = childRC.NewExpressionEvaluator(context.Background())
child := &stepRun{RunContext: childRC, Step: &model.Step{ID: "child"}, env: map[string]string{}}
err := runStepExecutor(outer, stepStageMain, func(ctx context.Context) error {
require.NoError(t, runStepExecutor(child, stepStageMain, func(context.Context) error {
require.NoError(t, os.WriteFile(child.env["GITHUB_OUTPUT"], []byte("declared=child\nundeclared=leak\n"), 0o600))
require.NoError(t, os.WriteFile(child.env["GITHUB_STATE"], []byte("saved=child\n"), 0o600))
return nil
})(ctx))
outer.RunContext.setOutput(ctx, map[string]string{"name": "declared"}, "outer")
return nil
})(context.Background())
require.NoError(t, err)
assert.Equal(t, map[string]string{"declared": "outer"}, outer.RunContext.StepResults[outer.Step.ID].Outputs)
assert.Equal(t, map[string]string{"declared": "child", "undeclared": "leak"}, childRC.StepResults[child.Step.ID].Outputs)
assert.Empty(t, outer.RunContext.IntraActionState)
assert.Equal(t, "child", childRC.IntraActionState[child.Step.ID]["saved"])
})
t.Run("timeout must be positive", func(t *testing.T) {
exprEval := createRunContext(t).NewExpressionEvaluator(context.Background())
for timeout, wantDeadline := range map[string]bool{"-1": false, "0": false, "1": true} {
ctx, cancel := evaluateStepTimeout(context.Background(), exprEval, &model.Step{TimeoutMinutes: timeout})
_, hasDeadline := ctx.Deadline()
cancel()
assert.Equal(t, wantDeadline, hasDeadline, timeout)
}
})
}
+6
View File
@@ -0,0 +1,6 @@
name: docker-proxy
description: Verify mounted Docker access through post steps
runs:
using: node24
main: index.js
post: index.js
+53
View File
@@ -0,0 +1,53 @@
const assert = require('node:assert/strict');
const {once} = require('node:events');
const fs = require('node:fs');
const http = require('node:http');
async function request(method, path, body) {
const req = http.request({
socketPath: '/var/run/docker.sock',
method,
path,
headers: {'Content-Type': 'application/json'},
signal: AbortSignal.timeout(10000),
});
req.end(JSON.stringify(body));
const [res] = await once(req, 'response');
req.on('error', (error) => res.destroy(error));
let data = '';
for await (const chunk of res.setEncoding('utf8')) {
data += chunk;
}
assert(res.statusCode >= 200 && res.statusCode < 300, `${method} ${path}: ${res.statusCode} ${data}`);
return data;
}
async function main() {
assert(fs.statSync('/var/run/docker.sock').isSocket(), 'Docker mount must be a socket');
assert.equal(await request('GET', '/_ping'), 'OK');
const api = `/v${JSON.parse(await request('GET', '/version')).ApiVersion}`;
const name = process.env.PROXY_TEST_RESOURCE;
const job = process.env.JOB_CONTAINER_NAME;
const post = process.env.STATE_post === 'true';
assert(name);
assert(job);
if (!post) {
await request('POST', `${api}/volumes/create`, {Name: name});
await request('POST', `${api}/networks/create`, {Name: name});
await request('POST', `${api}/networks/${name}/connect`, {Container: job});
fs.appendFileSync(process.env.GITHUB_STATE, 'post=true\n');
}
const label = process.env.PROXY_TEST_MODE === 'proxy' ? job : undefined;
assert.equal(JSON.parse(await request('GET', `${api}/volumes/${name}`)).Labels?.['com.gitea.runner.job'], label);
const network = JSON.parse(await request('GET', `${api}/networks/${name}`));
assert.equal(network.Labels?.['com.gitea.runner.job'], label);
assert(Object.values(network.Containers).some((container) => container.Name === job));
if (post) {
console.log('docker proxy post verified');
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+8
View File
@@ -0,0 +1,8 @@
name: docker-proxy
on: push
jobs:
proxy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./action
+21
View File
@@ -28,3 +28,24 @@ func testActionsCacheRoundTrip(t *testing.T) {
}) })
} }
} }
func testArtifactRoundTrip(t *testing.T) {
t.Parallel()
v2 := true
for _, tc := range []struct {
name string
options runnerOptions
}{
{"through_the_cache_server", runnerOptions{cacheV2: &v2}},
{"direct_with_no_reachable_cache", runnerOptions{cacheHost: "cache.invalid", cacheV2: new(bool)}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
api, repo, _ := startIsolatedScenario(t, "artifact.yml", "e2e-artifact", tc.options)
wfRun := waitForRun(t, api, repo)
requireSuccess(t, api, repo, wfRun.ID)
})
}
}
+1
View File
@@ -27,6 +27,7 @@ func TestCompatibility(t *testing.T) {
t.Run("payloads", testPayloads) t.Run("payloads", testPayloads)
t.Run("cache", testActionsCacheRoundTrip) t.Run("cache", testActionsCacheRoundTrip)
t.Run("artifact", testArtifactRoundTrip)
t.Run("cancellation_and_log_streaming", testRunCancellation) t.Run("cancellation_and_log_streaming", testRunCancellation)
t.Run("dispatch", testWorkflowDispatch) t.Run("dispatch", testWorkflowDispatch)
t.Run("ephemeral", testEphemeralRunner) t.Run("ephemeral", testEphemeralRunner)
+2 -2
View File
@@ -160,7 +160,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
sharedNet, _ = selfNetwork(ctx, cli) sharedNet, _ = selfNetwork(ctx, cli)
) )
if sharedNet != "" { if sharedNet != "" {
baseURL = fmt.Sprintf("http://%s:3000", name) baseURL = "http://" + net.JoinHostPort(name, "3000")
netConfig = &network.NetworkingConfig{ netConfig = &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}}, EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}},
} }
@@ -170,7 +170,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
if err != nil { if err != nil {
return nil, fmt.Errorf("find a free host port: %w", err) return nil, fmt.Errorf("find a free host port: %w", err)
} }
baseURL = fmt.Sprintf("http://%s:%d", host, port) baseURL = "http://" + net.JoinHostPort(host.String(), strconv.Itoa(port))
hostConfig.PortBindings = network.PortMap{ hostConfig.PortBindings = network.PortMap{
containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}}, containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}},
} }
+2
View File
@@ -27,6 +27,7 @@ type runnerOptions struct {
capacity int capacity int
ephemeral bool ephemeral bool
cacheV2 *bool cacheV2 *bool
cacheHost string
} }
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller { func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
@@ -49,6 +50,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
cfg.Container.DockerHost = "unix:///var/run/docker.sock" cfg.Container.DockerHost = "unix:///var/run/docker.sock"
} }
cfg.Cache.Dir = t.TempDir() + "/cache" cfg.Cache.Dir = t.TempDir() + "/cache"
cfg.Cache.Host = options.cacheHost
cfg.Runner.Insecure = true cfg.Runner.Insecure = true
cfg.Runner.FetchInterval = 250 * time.Millisecond // faster than prod defaults for local fixture cfg.Runner.FetchInterval = 250 * time.Millisecond // faster than prod defaults for local fixture
cfg.Runner.FetchIntervalMax = 250 * time.Millisecond cfg.Runner.FetchIntervalMax = 250 * time.Millisecond
+28
View File
@@ -0,0 +1,28 @@
name: artifact
on: push
jobs:
upload:
runs-on: e2e-artifact
steps:
- run: |
echo "results=$ACTIONS_RESULTS_URL cache=$ACTIONS_CACHE_URL v2=${ACTIONS_CACHE_SERVICE_V2:-unset}"
if [ "$ACTIONS_CACHE_SERVICE_V2" = "true" ]; then
test "$ACTIONS_RESULTS_URL/" = "$ACTIONS_CACHE_URL"
else
test "$ACTIONS_RESULTS_URL/" != "$ACTIONS_CACHE_URL"
fi
mkdir -p out
echo "artifact-payload" > out/data.txt
- uses: actions/upload-artifact@v7
with:
name: e2e-artifact-${{ github.run_id }}
path: out/data.txt
download:
needs: upload
runs-on: e2e-artifact
steps:
- uses: actions/download-artifact@v8
with:
name: e2e-artifact-${{ github.run_id }}
path: got
- run: grep -q artifact-payload got/data.txt
+5 -5
View File
@@ -2,7 +2,7 @@ module gitea.com/gitea/runner
go 1.27 go 1.27
toolchain go1.27.0 toolchain go1.27.1
require ( require (
connectrpc.com/connect v1.20.0 connectrpc.com/connect v1.20.0
@@ -17,7 +17,7 @@ require (
github.com/docker/go-units v0.5.0 github.com/docker/go-units v0.5.0
github.com/go-git/go-billy/v5 v5.9.1 github.com/go-git/go-billy/v5 v5.9.1
github.com/go-git/go-git/v5 v5.19.2 github.com/go-git/go-git/v5 v5.19.2
github.com/gobwas/glob v0.2.3 github.com/gobwas/glob v1.0.0
github.com/google/go-cmp v0.7.0 github.com/google/go-cmp v0.7.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
@@ -30,8 +30,8 @@ require (
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/opencontainers/selinux v1.15.1 github.com/opencontainers/selinux v1.15.1
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.2 github.com/prometheus/client_model v0.6.3
github.com/sirupsen/logrus v1.10.1 github.com/sirupsen/logrus v1.10.2
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.12.1
@@ -102,6 +102,6 @@ require (
go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect golang.org/x/crypto v0.56.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
) )
+8 -8
View File
@@ -77,8 +77,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -157,8 +157,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
@@ -173,8 +173,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -234,8 +234,8 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+2 -6
View File
@@ -6,8 +6,6 @@ package cmd
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"os/signal"
"gitea.com/gitea/runner/act/artifactcache" "gitea.com/gitea/runner/act/artifactcache"
"gitea.com/gitea/runner/internal/app/run" "gitea.com/gitea/runner/internal/app/run"
@@ -67,10 +65,8 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
log.Infof("cache server is listening on %v", cacheHandler.ExternalURL()) log.Infof("cache server is listening on %v", cacheHandler.ExternalURL())
c := make(chan os.Signal, 1) <-cmd.Context().Done()
signal.Notify(c, os.Interrupt)
<-c
return nil return cacheHandler.Close()
} }
} }
+1 -1
View File
@@ -82,7 +82,7 @@ func Execute(ctx context.Context) {
// hide completion command // hide completion command
rootCmd.CompletionOptions.HiddenDefaultCmd = true rootCmd.CompletionOptions.HiddenDefaultCmd = true
if err := rootCmd.Execute(); err != nil { if err := rootCmd.ExecuteContext(ctx); err != nil {
os.Exit(1) os.Exit(1)
} }
} }
+124 -62
View File
@@ -5,9 +5,11 @@
package cmd package cmd
import ( import (
"cmp"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
@@ -45,6 +47,8 @@ type executeArgs struct {
forcePull bool forcePull bool
forceRebuild bool forceRebuild bool
jsonLogger bool jsonLogger bool
inputs []string
inputfile string
envs []string envs []string
envfile string envfile string
secrets []string secrets []string
@@ -90,6 +94,11 @@ func (i *executeArgs) Envfile() string {
return i.resolve(i.envfile) return i.resolve(i.envfile)
} }
// Inputfile returns path to .env-format inputfile
func (i *executeArgs) Inputfile() string {
return i.resolve(i.inputfile)
}
func (i *executeArgs) LoadSecrets() map[string]string { func (i *executeArgs) LoadSecrets() map[string]string {
s := make(map[string]string) s := make(map[string]string)
for _, secretPair := range i.secrets { for _, secretPair := range i.secrets {
@@ -128,41 +137,79 @@ func readEnvs(path string, envs map[string]string) bool {
return false return false
} }
func (i *executeArgs) LoadEnvs() map[string]string { func (i *executeArgs) LoadVars() map[string]string {
envs := make(map[string]string) return parseKVAndFile(i.vars, "")
if i.envs != nil { }
for _, envVar := range i.envs {
e := strings.SplitN(envVar, `=`, 2) func (i *executeArgs) LoadInputs() map[string]string {
if len(e) == 2 { return parseKVAndFile(i.inputs, i.Inputfile())
envs[e[0]] = e[1] }
} else {
envs[e[0]] = "" // eventJSON assembles the payload the run is triggered with, the `--input` values overriding
} // the inputs the `--eventpath` file carries.
func (i *executeArgs) eventJSON() (string, error) {
payload := []byte("{}")
if path := i.resolve(i.eventpath); path != "" {
var err error
if payload, err = os.ReadFile(path); err != nil {
return "", fmt.Errorf("failed to read %s: %w", path, err)
} }
} }
_ = readEnvs(i.Envfile(), envs)
cliInputs := i.LoadInputs()
if len(cliInputs) == 0 {
return string(payload), nil
}
var event map[string]any
if err := json.Unmarshal(payload, &event); err != nil {
return "", fmt.Errorf("failed to parse event payload: %w", err)
}
if event == nil { // a JSON `null` payload unmarshals into a nil map
event = make(map[string]any)
}
inputs, ok := event["inputs"].(map[string]any)
if !ok {
inputs = make(map[string]any)
}
for name, value := range cliInputs {
inputs[name] = value
}
event["inputs"] = inputs
merged, err := json.Marshal(event)
if err != nil {
return "", fmt.Errorf("failed to marshal event payload: %w", err)
}
return string(merged), nil
}
func (i *executeArgs) LoadEnvs() map[string]string {
envs := parseKVAndFile(i.envs, i.Envfile())
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/" envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
// The same server answers the cache service v2 API, so let the actions reach it. // The same server answers cache v2, which docker buildx reads from the results origin alone.
if envs["ACTIONS_RESULTS_URL"] == "" {
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), i.cacheHandler.ExternalURL())
}
envs[runner.CacheServiceV2Env] = "true" envs[runner.CacheServiceV2Env] = "true"
return envs return envs
} }
func (i *executeArgs) LoadVars() map[string]string { func parseKVAndFile(rawKVs []string, filePath string) map[string]string {
vars := make(map[string]string) result := make(map[string]string)
if i.vars != nil { _ = readEnvs(filePath, result)
for _, runVar := range i.vars {
e := strings.SplitN(runVar, `=`, 2) for _, raw := range rawKVs {
if len(e) == 2 { parts := strings.SplitN(raw, "=", 2)
vars[e[0]] = e[1] if len(parts) == 2 {
} else { result[parts[0]] = parts[1]
vars[e[0]] = "" } else {
} result[parts[0]] = ""
} }
} }
return result
return vars
} }
// Workdir returns path to workdir // Workdir returns path to workdir
@@ -329,6 +376,52 @@ func runExecList(planner model.WorkflowPlanner, execArgs *executeArgs) error {
return nil return nil
} }
func (i *executeArgs) runnerConfig(eventName string, env, proxyEnv map[string]string, maxLifetime time.Duration, sharedToolCache bool) (*runner.Config, error) {
eventJSON, err := i.eventJSON()
if err != nil {
return nil, err
}
return &runner.Config{
Workdir: i.Workdir(),
BindWorkdir: false,
ForcePull: i.forcePull,
ForceRebuild: i.forceRebuild,
JSONLogger: i.jsonLogger,
Env: env,
ProxyEnv: proxyEnv,
Vars: i.LoadVars(),
Secrets: i.LoadSecrets(),
InsecureSecrets: i.insecureSecrets,
Privileged: i.privileged,
UsernsMode: i.usernsMode,
ContainerArchitecture: i.containerArchitecture,
ContainerDaemonSocket: i.containerDaemonSocket,
UseGitIgnore: i.useGitIgnore,
GitHubInstance: i.githubInstance,
ContainerCapAdd: i.containerCapAdd,
ContainerCapDrop: i.containerCapDrop,
ContainerOptions: i.containerOptions,
ArtifactServerPath: i.artifactServerPath,
ArtifactServerPort: i.artifactServerPort,
ArtifactServerAddr: i.artifactServerAddr,
NoSkipCheckout: i.noSkipCheckout,
EventName: eventName,
EventJSON: eventJSON,
// PresetGitHubContext: preset,
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(i.network),
DefaultActionInstance: i.defaultActionsURL,
DefaultActionInstanceIsSelfHosted: i.defaultActionsURL != "" && i.defaultActionsURL != "https://github.com",
PlatformPicker: func(_ []string) string {
return i.image
},
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
SharedToolCache: sharedToolCache,
}, nil
}
func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error { func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error {
planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse) planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse)
@@ -445,46 +538,13 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
} }
// run the plan // run the plan
config := &runner.Config{ config, err := execArgs.runnerConfig(eventName, env, proxyEnv, maxLifetime, shared)
Workdir: execArgs.Workdir(), if err != nil {
BindWorkdir: false, return err
ForcePull: execArgs.forcePull,
ForceRebuild: execArgs.forceRebuild,
JSONLogger: execArgs.jsonLogger,
Env: env,
ProxyEnv: proxyEnv,
Vars: execArgs.LoadVars(),
Secrets: execArgs.LoadSecrets(),
InsecureSecrets: execArgs.insecureSecrets,
Privileged: execArgs.privileged,
UsernsMode: execArgs.usernsMode,
ContainerArchitecture: execArgs.containerArchitecture,
ContainerDaemonSocket: execArgs.containerDaemonSocket,
UseGitIgnore: execArgs.useGitIgnore,
GitHubInstance: execArgs.githubInstance,
ContainerCapAdd: execArgs.containerCapAdd,
ContainerCapDrop: execArgs.containerCapDrop,
ContainerOptions: execArgs.containerOptions,
ArtifactServerPath: execArgs.artifactServerPath,
ArtifactServerPort: execArgs.artifactServerPort,
ArtifactServerAddr: execArgs.artifactServerAddr,
NoSkipCheckout: execArgs.noSkipCheckout,
EventPath: execArgs.resolve(execArgs.eventpath),
// PresetGitHubContext: preset,
// EventJSON: string(eventJSON),
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(execArgs.network),
DefaultActionInstance: execArgs.defaultActionsURL,
DefaultActionInstanceIsSelfHosted: execArgs.defaultActionsURL != "" && execArgs.defaultActionsURL != "https://github.com",
PlatformPicker: func(_ []string) string {
return execArgs.image
},
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
SharedToolCache: shared,
} }
config.Env["ACT_EXEC"] = "true" config.Env["ACT_EXEC"] = "true"
config.Secrets[actionsRuntimeTokenEnvName] = actionsRuntimeToken
if t := config.Secrets["GITEA_TOKEN"]; t != "" { if t := config.Secrets["GITEA_TOKEN"]; t != "" {
config.Token = t config.Token = t
@@ -536,7 +596,9 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "pull docker image(s) even if already present") execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "pull docker image(s) even if already present")
execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "rebuild local action docker image(s) even if already present") execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "rebuild local action docker image(s) even if already present")
execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "Output logs in json format") execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "Output logs in json format")
execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv)") execCmd.Flags().StringArrayVarP(&execArg.inputs, "input", "", []string{}, "set an input the workflow declares under its workflow_dispatch or workflow_call trigger, others stay invisible to the inputs context (e.g. --input name=bar; can be specified multiple times with highest precedence)")
execCmd.Flags().StringVarP(&execArg.inputfile, "input-file", "", "", "path to an .env-format file containing key=value pairs as baseline workflow inputs (override event inputs)")
execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv; override env-file)")
execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers") execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers")
execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)") execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)")
execCmd.Flags().StringArrayVarP(&execArg.vars, "var", "", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)") execCmd.Flags().StringArrayVarP(&execArg.vars, "var", "", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)")
+94
View File
@@ -12,9 +12,12 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.com/gitea/runner/act/artifactcache"
"gitea.com/gitea/runner/act/runner"
"gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/config"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -56,6 +59,81 @@ func TestExecuteArgsPaths(t *testing.T) {
require.Equal(t, workdir, args.Workdir()) require.Equal(t, workdir, args.Workdir())
} }
func TestExecuteArgsLoadInputs(t *testing.T) {
require.Empty(t, (&executeArgs{}).LoadInputs())
dir := t.TempDir()
envFile := filepath.Join(dir, ".env")
require.NoError(t, os.WriteFile(envFile, []byte("BAZ=qux\nOVERRIDE=from_file\n"), 0o600))
args := &executeArgs{inputs: []string{"FOO=bar", "EMPTY", "WITH=eq=sign", "OVERRIDE=from_cli"}, inputfile: envFile}
require.Equal(t, map[string]string{
"FOO": "bar",
"EMPTY": "",
"WITH": "eq=sign",
"BAZ": "qux",
"OVERRIDE": "from_cli",
}, args.LoadInputs())
}
func TestExecuteArgsEventJSON(t *testing.T) {
payload := func(content string) string {
path := filepath.Join(t.TempDir(), "event.json")
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return path
}
tests := []struct {
name string
eventpath string
inputs []string
expected string
}{
{
name: "keeps a payload without inputs to merge",
eventpath: payload(`{"inputs": {"kept": "from_event"}}`),
expected: `{"inputs": {"kept": "from_event"}}`,
},
{
name: "overrides the event inputs it names, keeping the rest",
eventpath: payload(`{"inputs": {"kept": "from_event", "named": "from_event"}}`),
inputs: []string{"named=from_cli"},
expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`,
},
{
name: "adds inputs to a payload carrying none",
eventpath: payload(`{"repository": {"name": "test-repo"}}`),
inputs: []string{"flag=val"},
expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`,
},
{
name: "handles a null payload",
eventpath: payload("null"),
inputs: []string{"flag=val"},
expected: `{"inputs": {"flag": "val"}}`,
},
{
name: "builds a payload without an event file",
inputs: []string{"flag=val"},
expected: `{"inputs": {"flag": "val"}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eventJSON, err := (&executeArgs{eventpath: tt.eventpath, inputs: tt.inputs}).eventJSON()
require.NoError(t, err)
require.JSONEq(t, tt.expected, eventJSON)
})
}
args := &executeArgs{inputs: []string{"flag=val"}}
config, err := args.runnerConfig("workflow_dispatch", nil, nil, 0, false)
require.NoError(t, err)
require.JSONEq(t, `{"inputs": {"flag": "val"}}`, config.EventJSON)
require.Equal(t, "workflow_dispatch", config.EventName) // the inputs context stays empty without it
}
func TestExecuteArgsLoadVars(t *testing.T) { func TestExecuteArgsLoadVars(t *testing.T) {
require.Empty(t, (&executeArgs{}).LoadVars()) require.Empty(t, (&executeArgs{}).LoadVars())
@@ -232,3 +310,19 @@ func captureStdout(t *testing.T, fn func()) string {
require.NoError(t, r.Close()) require.NoError(t, r.Close())
return buf.String() return buf.String()
} }
func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) {
t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
require.NoError(t, err)
t.Cleanup(func() { _ = handler.Close() })
args := &executeArgs{cacheHandler: handler}
envs := args.LoadEnvs()
assert.Equal(t, handler.ExternalURL(), envs["ACTIONS_RESULTS_URL"],
"the v2 flag is advertised, so something has to serve that origin")
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
args.envs = []string{"ACTIONS_RESULTS_URL=https://gitea.example"}
assert.Equal(t, "https://gitea.example", args.LoadEnvs()["ACTIONS_RESULTS_URL"], "a supplied origin wins")
}
+48 -13
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"maps" "maps"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -89,7 +90,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
envs := make(map[string]string, len(cfg.Runner.Envs)) envs := make(map[string]string, len(cfg.Runner.Envs))
maps.Copy(envs, cfg.Runner.Envs) maps.Copy(envs, cfg.Runner.Envs)
var cacheHandler *artifactcache.Handler var cacheHandler *artifactcache.Handler
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled { if cacheEnabled(cfg) {
if cfg.Cache.ExternalServer != "" { if cfg.Cache.ExternalServer != "" {
warnIgnoredCachePolicy(cfg) warnIgnoredCachePolicy(cfg)
// The v1 client appends its path to this without a separator, so the slash is required. // The v1 client appends its path to this without a separator, so the slash is required.
@@ -109,6 +110,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
} else { } else {
cacheHandler = handler cacheHandler = handler
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/" envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
warnIfCacheUnreachable(cfg, handler.ExternalURL())
} }
} }
} }
@@ -279,10 +281,9 @@ func (r *Runner) SetCapabilitiesFromDeclare(resp *connect.Response[runnerv1.Decl
} }
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error { func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
if _, ok := r.runningTasks.Load(task.Id); ok { if _, ok := r.runningTasks.LoadOrStore(task.Id, struct{}{}); ok {
return fmt.Errorf("task %d is already running", task.Id) return fmt.Errorf("task %d is already running", task.Id)
} }
r.runningTasks.Store(task.Id, struct{}{})
defer r.runningTasks.Delete(task.Id) defer r.runningTasks.Delete(task.Id)
r.runningCount.Add(1) r.runningCount.Add(1)
@@ -388,7 +389,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// Added per task because this job's service containers must be reached directly, and // Added per task because this job's service containers must be reached directly, and
// act reaches them by their workflow key. // act reaches them by their workflow key.
proxyEnv := JobProxyEnv(envs, envs["ACTIONS_CACHE_URL"], slices.Sorted(maps.Keys(job.Services))) proxyEnv := JobProxyEnv(envs, r.builtInCacheURL(), slices.Sorted(maps.Keys(job.Services)))
maps.Copy(envs, proxyEnv) maps.Copy(envs, proxyEnv)
if r.capabilities != "" { if r.capabilities != "" {
@@ -454,14 +455,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// is that server's responsibility to authenticate requests. // is that server's responsibility to authenticate requests.
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter) revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)
defer revokeCache() defer revokeCache()
// A cache server that agreed to forward the artifact half is the whole results service, so the r.setResultsService(envs, resultsURL)
// job is pointed at it.
if resultsURL != "" {
envs["ACTIONS_RESULTS_URL"] = resultsURL
if r.cacheServiceV2() {
envs[runner.CacheServiceV2Env] = "true"
}
}
eventJSON, err := json.Marshal(preset.Event) eventJSON, err := json.Marshal(preset.Event)
if err != nil { if err != nil {
@@ -584,12 +578,43 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
return execErr return execErr
} }
func (r *Runner) builtInCacheURL() string {
if r.cacheHandler == nil {
return ""
}
return r.envs["ACTIONS_CACHE_URL"]
}
func cacheEnabled(cfg *config.Config) bool {
return cfg.Cache.Enabled == nil || *cfg.Cache.Enabled
}
// cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2 // cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2
// turns off: the bundle edit that reaches it is what the artifact actions need too. // turns off: the bundle edit that reaches it is what the artifact actions need too.
func (r *Runner) cacheServiceV2() bool { func (r *Runner) cacheServiceV2() bool {
return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2 return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2
} }
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
v2 := resultsURL != "" && r.cacheServiceV2()
if v2 {
envs[runner.CacheServiceV2Env] = "true"
} else {
delete(envs, runner.CacheServiceV2Env)
}
// Cache v2 clients read no other variable for it, and some cannot use the instance address.
if v2 || (resultsURL != "" && r.instanceOutOfReach(envs["ACTIONS_RESULTS_URL"])) {
envs["ACTIONS_RESULTS_URL"] = resultsURL
}
}
// These clients keep only the origin they are handed, and reach it on the job's own trust.
func (r *Runner) instanceOutOfReach(instance string) bool {
parsed, err := url.Parse(instance)
return err != nil || strings.Trim(parsed.Path, "/") != "" ||
(r.cfg.Runner.Insecure && parsed.Scheme == "https")
}
// registerCacheForTask tells the cache server to accept requests authenticated // registerCacheForTask tells the cache server to accept requests authenticated
// with the given runtime token for the duration of this task. Returns a // with the given runtime token for the duration of this task. Returns a
// function the caller must invoke (typically via defer) to revoke the // function the caller must invoke (typically via defer) to revoke the
@@ -616,7 +641,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
if r.cacheHandler != nil { if r.cacheHandler != nil {
return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred) return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred)
} }
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" { if cacheEnabled(r.cfg) && r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
return r.registerExternalCacheJob(token, cred, reporter) return r.registerExternalCacheJob(token, cred, reporter)
} }
// No cache server to register against: caching is disabled, or the built-in server failed to start. // No cache server to register against: caching is disabled, or the built-in server failed to start.
@@ -799,3 +824,13 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
} }
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key) log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
} }
func warnIfCacheUnreachable(cfg *config.Config, cacheURL string) {
if cfg.Cache.Host != "" || cfg.Container.Network != "" {
return
}
if _, err := os.Stat("/.dockerenv"); err != nil {
return
}
log.Warnf("jobs are given %s for the cache server; if they cannot reach it, set container.network to a network this runner is on, or cache.host", cacheURL)
}
+7
View File
@@ -194,6 +194,13 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, probe(), require.Equal(t, http.StatusUnauthorized, probe(),
"token must be unknown to the remote server before registration") "token must be unknown to the remote server before registration")
disabled := &Runner{cfg: &config.Config{Cache: config.Cache{
Enabled: new(bool), ExternalServer: external, ExternalSecret: secret,
}}}
disabled.registerCacheForTask(token, repo, nil)
require.Equal(t, http.StatusUnauthorized, probe(),
"a disabled cache registers nothing, whatever external server is left configured")
unregister, resultsURL := r.registerCacheForTask(token, repo, nil) unregister, resultsURL := r.registerCacheForTask(token, repo, nil)
require.NotEqual(t, http.StatusUnauthorized, probe(), require.NotEqual(t, http.StatusUnauthorized, probe(),
"token must be accepted after registerCacheForTask") "token must be accepted after registerCacheForTask")
+31 -3
View File
@@ -143,6 +143,7 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
require.NotContains(t, r.envs, "http_proxy") require.NotContains(t, r.envs, "http_proxy")
require.NotContains(t, r.envs, "no_proxy") require.NotContains(t, r.envs, "no_proxy")
assert.Empty(t, r.builtInCacheURL(), "an external cache server is the operator's to exempt, not ours")
} }
func taskWithDefaultActionsURL(url string) *runnerv1.Task { func taskWithDefaultActionsURL(url string) *runnerv1.Task {
@@ -189,11 +190,38 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service") assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
assert.Equal(t, r.envs["ACTIONS_CACHE_URL"], r.builtInCacheURL(), "the address only the runner knows is bypassed for the operator")
envs := r.cloneEnvs()
r.setResultsService(envs, resultsURL)
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"])
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
// Turning v2 off withdraws the advertisement and nothing else.
assert.True(t, r.cacheServiceV2())
cfg.Cache.V2 = new(bool) cfg.Cache.V2 = new(bool)
assert.False(t, r.cacheServiceV2()) envs = r.cloneEnvs()
envs[runner.CacheServiceV2Env] = "true"
r.setResultsService(envs, resultsURL)
assert.Equal(t, "https://gitea.example", envs["ACTIONS_RESULTS_URL"])
assert.Empty(t, envs[runner.CacheServiceV2Env], "a runner.envs entry would promise v2 at an origin not serving it")
envs = r.cloneEnvs()
envs[runner.CacheServiceV2Env] = "true"
envs["ACTIONS_RESULTS_URL"] = "https://gitea.example/sub"
r.setResultsService(envs, "")
assert.Equal(t, "https://gitea.example/sub", envs["ACTIONS_RESULTS_URL"], "with no cache server there is nothing to front with")
assert.Empty(t, envs[runner.CacheServiceV2Env])
for instance, insecure := range map[string]bool{
"https://gitea.example/sub": false,
"https://self-signed.example": true,
} {
cfg.Runner.Insecure = insecure
envs = r.cloneEnvs()
envs["ACTIONS_RESULTS_URL"] = instance
r.setResultsService(envs, resultsURL)
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"], instance)
assert.Empty(t, envs[runner.CacheServiceV2Env])
}
} }
// The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured // The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured
+9 -9
View File
@@ -139,7 +139,8 @@ runner:
# job_completed: '' # job_completed: ''
cache: cache:
# Enable the built-in cache server (used by actions/cache and similar actions). # Enable caching (used by actions/cache and similar actions). Off means no built-in server is
# started and no job is registered with an external_server either.
#enabled: true #enabled: true
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache # Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
# Ignored when external_server is set. # Ignored when external_server is set.
@@ -176,9 +177,9 @@ cache:
# until its cache entry expires or is manually removed. # until its cache entry expires or is manually removed.
#offline_mode: false #offline_mode: false
# Serve the actions cache service v2 API. The actions that use it refuse any host they do not # Serve the actions cache service v2 API. The actions that use it refuse any host they do not
# take for GitHub, so reaching it means editing that check out of their own bundle, undone # take for GitHub, so reaching it means editing that check out of their own bundle, put back
# whenever it is downloaded again. That edit is made either way, this only governs the API # after the copy into the job. That edit is made either way, this only governs the API
# advertised. A bundle that does not match is left alone. # advertised. A bundle that does not match is left alone. With v2, uploads need a reachable cache.
#v2: true #v2: true
# How the cache server discards entries, ignored when external_server is set since that # How the cache server discards entries, ignored when external_server is set since that
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three # server applies its own. Leave a setting out for its default; 0s or 0 turns the three
@@ -256,11 +257,10 @@ container:
#require_docker: false #require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner # Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
#docker_timeout: 0s #docker_timeout: 0s
# Bind the workspace to the host filesystem instead of using Docker volumes. # Mount the workspace from a host directory instead of a Docker volume, so jobs
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose # can bind-mount it by its own path into sibling containers (".:/app" in docker
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible # compose). Not needed when workflows use the GITEA_DOCKER_WORKSPACE variable.
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent # The workspace parent directory must be mounted into the runner container.
# directory is also mounted into the runner container and listed in valid_volumes.
#bind_workdir: false #bind_workdir: false
# How long a job waits for a service container that declares a healthcheck to become # How long a job waits for a service container that declares a healthcheck to become
# healthy. A negative value (e.g. -1s) starts the steps without waiting. # healthy. A negative value (e.g. -1s) starts the steps without waiting.
+1 -1
View File
@@ -157,7 +157,7 @@ type Container struct {
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts. BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir mounts the workspace from a host directory instead of a Docker volume.
ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting. ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting.
} }
+11 -3
View File
@@ -783,8 +783,9 @@ var cmdRegex = regexp.MustCompile(`^::([^ :]+)( [^:]*)?::(.*)$`)
// handleCommand takes value still escaped, so that the web UI decodes it exactly once. Only // handleCommand takes value still escaped, so that the web UI decodes it exactly once. Only
// the branches that consume the payload here decode it. // the branches that consume the payload here decode it.
func (r *Reporter) handleCommand(originalContent, command, properties, value string) *string { func (r *Reporter) handleCommand(originalContent, command, properties, value string) *string {
command = strings.ToLower(command) // GitHub matches command names case-insensitively
if r.stopCommandEndToken != "" { if r.stopCommandEndToken != "" {
if command != r.stopCommandEndToken { if !strings.EqualFold(command, r.stopCommandEndToken) {
return &originalContent return &originalContent
} }
// Resumed here rather than from the switch, because the end token is arbitrary and a // Resumed here rather than from the switch, because the end token is arbitrary and a
@@ -871,13 +872,20 @@ func parseCommandProperties(properties string) map[string]string {
return props return props
} }
func cutPrefixFold(s, prefix string) (string, bool) {
if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) {
return s, false
}
return s[len(prefix):], true
}
func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow { func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
content := strings.TrimRight(entry.Message, "\r\n") content := strings.TrimRight(entry.Message, "\r\n")
// cmdRegex only covers the ::cmd:: form, so the ##[add-mask] one would otherwise reach // cmdRegex only covers the ::cmd:: form, so the ##[add-mask] one would otherwise reach
// the log carrying its own secret. Registered and dropped like its ::add-mask:: twin. // the log carrying its own secret. Registered and dropped like its ::add-mask:: twin.
if arg, ok := strings.CutPrefix(content, "##[add-mask]"); ok { if arg, ok := cutPrefixFold(content, "##[add-mask]"); ok {
r.addMask(runner.UnescapeCommandData(arg)) r.addMask(runner.UnescapeLegacyCommand(arg))
return nil return nil
} }
+3 -2
View File
@@ -23,6 +23,7 @@ import (
"gitea.com/gitea/runner/internal/pkg/metrics" "gitea.com/gitea/runner/internal/pkg/metrics"
connect_go "connectrpc.com/connect" connect_go "connectrpc.com/connect"
"gitea.dev/actionslib/pkg/model"
runnerv1 "gitea.dev/actionslib/runner/v1" runnerv1 "gitea.dev/actionslib/runner/v1"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test" logrustest "github.com/sirupsen/logrus/hooks/test"
@@ -273,14 +274,14 @@ func TestReporter_Fire(t *testing.T) {
"stepID": []string{"0", "0"}, "stepID": []string{"0", "0"},
"stepNumber": 0, "stepNumber": 0,
"raw_output": true, "raw_output": true,
"stepResult": "failure", "stepResult": model.StepStatusFailure,
}})) }}))
assert.Equal(t, runnerv1.Result_RESULT_UNSPECIFIED, reporter.state.Steps[0].Result) assert.Equal(t, runnerv1.Result_RESULT_UNSPECIFIED, reporter.state.Steps[0].Result)
require.NoError(t, reporter.Fire(&log.Entry{Message: "step result", Data: map[string]any{ require.NoError(t, reporter.Fire(&log.Entry{Message: "step result", Data: map[string]any{
"stage": "Main", "stage": "Main",
"stepNumber": 0, "stepNumber": 0,
"raw_output": true, "raw_output": true,
"stepResult": "success", "stepResult": model.StepStatusSuccess,
}})) }}))
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, reporter.state.Steps[0].Result) assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, reporter.state.Steps[0].Result)
+56 -9
View File
@@ -9,10 +9,8 @@
# #
# Usage: scripts/test-dind.sh [target] [-- go-test-args...] # Usage: scripts/test-dind.sh [target] [-- go-test-args...]
# target: dind (default) or dind-rootless # target: dind (default) or dind-rootless
# go-test-args: passed verbatim to `go test`. The default exercises the daemon-facing tests # go-test-args: passed verbatim to `go test`. Defaults cover image env extraction,
# that need no registry access (a fresh daemon, e.g. on fork-PR CI, can't # symlink copying and a mounted Docker job using cached images.
# authenticate pulls): the env-extraction build (FROM scratch) and the #981
# /var/run symlink copy regression (which reuses a preloaded alpine).
# #
# Env: # Env:
# DIND_TEST_PORT host port for the daemon (default 32375) # DIND_TEST_PORT host port for the daemon (default 32375)
@@ -25,15 +23,25 @@ case "${1:-}" in
dind|dind-rootless) target="$1"; shift ;; dind|dind-rootless) target="$1"; shift ;;
esac esac
[ "${1:-}" = "--" ] && shift [ "${1:-}" = "--" ] && shift
[ $# -eq 0 ] && set -- -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/ default_tests=false
if [ $# -eq 0 ]; then
default_tests=true
set -- -count=1 -race -run '^TestDocker$' ./act/container/
fi
port="${DIND_TEST_PORT:-32375}" port="${DIND_TEST_PORT:-32375}"
name="gitea-runner-dind-test-$$" name="gitea-runner-dind-test-$$"
image="${DIND_TEST_IMAGE:-gitea-runner-${target}:dind-test}" image="${DIND_TEST_IMAGE:-gitea-runner-${target}:dind-test}"
# The host daemon endpoint, captured before DOCKER_HOST is pointed at the fresh dind daemon. # The host daemon endpoint, captured before DOCKER_HOST is pointed at the fresh dind daemon.
host_docker="${DOCKER_HOST:-unix:///var/run/docker.sock}" host_docker="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}"
test_dir=""
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; } cleanup() {
docker -H "$host_docker" rm -fv "$name" >/dev/null 2>&1 || true
if [ -n "$test_dir" ]; then
rm -rf "$test_dir"
fi
}
trap cleanup EXIT trap cleanup EXIT
if [ -z "${DIND_TEST_IMAGE:-}" ]; then if [ -z "${DIND_TEST_IMAGE:-}" ]; then
@@ -71,7 +79,7 @@ else
fi fi
# Create the dind container on the host daemon first, then repoint DOCKER_HOST at it: exporting # Create the dind container on the host daemon first, then repoint DOCKER_HOST at it: exporting
# DOCKER_HOST before `docker run` would make this `docker run` target the not-yet-existent dind. # DOCKER_HOST before `docker run` would make this `docker run` target the not-yet-existent dind.
docker run -d --privileged --name "$name" "${run_args[@]}" \ docker -H "$host_docker" run -d --privileged --name "$name" "${run_args[@]}" \
-e DOCKER_TLS_CERTDIR= \ -e DOCKER_TLS_CERTDIR= \
--entrypoint dockerd-entrypoint.sh \ --entrypoint dockerd-entrypoint.sh \
"$image" --host=tcp://0.0.0.0:2375 >/dev/null "$image" --host=tcp://0.0.0.0:2375 >/dev/null
@@ -82,11 +90,20 @@ for _ in $(seq 1 60); do
docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break
sleep 1 sleep 1
done done
if ! docker version --format 'server docker {{.Server.Version}}'; then
docker -H "$host_docker" logs "$name" >&2
exit 1
fi
# Seed the fresh daemon with images the host already has (the CI job pulls them in the # Seed the fresh daemon with images the host already has (the CI job pulls them in the
# preceding `make test`), so the daemon-facing tests run without registry access. # preceding `make test`), so the daemon-facing tests run without registry access.
echo "==> Seeding daemon with cached host images" echo "==> Seeding daemon with cached host images"
for img in ${DIND_TEST_PRELOAD:-alpine:latest}; do preload="${DIND_TEST_PRELOAD:-alpine:latest}"
job_image="${ACT_TEST_IMAGE:-node:24-bookworm-slim}"
if [ "$default_tests" = true ]; then
preload="$preload $job_image"
fi
for img in $preload; do
if docker -H "$host_docker" image inspect "$img" >/dev/null 2>&1; then if docker -H "$host_docker" image inspect "$img" >/dev/null 2>&1; then
docker -H "$host_docker" save "$img" | docker load >/dev/null 2>&1 && echo " loaded $img" || true docker -H "$host_docker" save "$img" | docker load >/dev/null 2>&1 && echo " loaded $img" || true
fi fi
@@ -94,3 +111,33 @@ done
echo "==> Running tests against dind daemon" echo "==> Running tests against dind daemon"
go test "$@" go test "$@"
if [ "$default_tests" = true ]; then
if ! docker image inspect "$job_image" >/dev/null 2>&1; then
echo "mounted Docker test requires ${job_image}, pull it on the host before running this harness" >&2
exit 1
fi
test_dir="$(mktemp -d)"
echo "==> Building mounted Docker test for the dind container"
CGO_ENABLED=0 GOOS=linux GOARCH="$(docker -H "$host_docker" image inspect "$image" --format '{{.Architecture}}')" \
go test -c -o "$test_dir/runner.test" ./act/runner/
docker -H "$host_docker" exec "$name" mkdir -p /tmp/gitea-runner-proxy-test/testdata
tar -C "$test_dir" -cf - runner.test -C "$PWD/act/runner" testdata/docker-proxy | \
docker -H "$host_docker" exec -i "$name" tar -x -C /tmp/gitea-runner-proxy-test
socket="unix:///var/run/docker.sock"
users=(0)
if [ "$target" = dind-rootless ]; then
socket="unix:///run/user/1000/docker.sock"
users=(1000 0)
fi
for user in "${users[@]}"; do
proxy_mode="proxy"
if [ "$user" != 0 ]; then
proxy_mode="direct"
fi
echo "==> Running mounted Docker job inside ${target} as UID ${user}, expecting ${proxy_mode} access"
docker -H "$host_docker" exec --user "$user" -w /tmp/gitea-runner-proxy-test \
-e DOCKER_HOST="$socket" -e ACT_TEST_DOCKER_PROXY="$proxy_mode" -e ACT_TEST_IMAGE="$job_image" \
"$name" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m
done
fi