Compare commits

...
17 Commits
Author SHA1 Message Date
Lunny XiaoandZettat123 ffd80101a3 enhance(kubernetes): gzip copies into job containers over exec (#1355)
The Kubernetes backend copies actions and other files into the job container with `tar` over `pods/exec`, so the data goes through the API server, which on managed clusters often sits in other zones than the nodes (each copied byte then crosses zones twice). Actions are megabytes of JavaScript bundles that gzip about fivefold:

| Action | tar | gzip |
|---|---|---|
| actions/setup-go@v5 | 8.0 MB | 1.5 MB |
| actions/cache@v4 | 11.6 MB | 2.0 MB |
| actions/setup-java@v4 | 15.3 MB | 2.5 MB |
| docker/build-push-action@v6 | 12.9 MB | 3.0 MB |

The startup probe now also reports whether the job container has `gzip`. If it does, `CopyDir` and copies of 64 KiB or more are gzipped at `BestSpeed` and extracted with `gzip -dc | tar -xf -`. Smaller copies (run scripts, file command files) stay uncompressed, where gzip would save next to nothing and cost a process in the pod. Images without `gzip` keep the plain tar stream, so the image requirements do not change.

This is independent of #1354 and gets most of its traffic saving with no new moving parts.

---
*Written by Codet (AI coding agent) on behalf of @lunny.*

---------

Co-authored-by: Zettat123 <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1355
Reviewed-by: Zettat123 <[email protected]>
2026-10-28 06:00:51 +00:00
b712c91c2d test(e2e): cover host mode, reusable workflows, scheduling, reconnect and CLI flags (#1347)
Extends the e2e suite with scenarios for host labels, local `workflow_call`, label routing, `runner.capacity`, a dropped and restored connection to Gitea, and the daemon's `--once`, `--labels` and `shutdown_timeout` behaviour.

Stacked on https://gitea.com/gitea/runner/pulls/1346, so its commit shows here until that merges. The new scenarios pass locally against gitea/gitea:latest and main-nightly.

Assisted-by: Codet:codet
Co-authored-by: Zettat123 <[email protected]>
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1347
Reviewed-by: Zettat123 <[email protected]>
2026-10-28 04:32:50 +00:00
d77e216674 fix(exec): serve the v4 artifact API so upload-artifact v4 and later work (#1341)
`exec` forwarded the toolkit's ArtifactService calls to an instance it never registered, and its random runtime token held no backend IDs, so `actions/upload-artifact` and `actions/download-artifact` v4 and later failed.

The local artifact server now serves the v4 API, including the single Put Blob uploads docker/build-push-action uses. `exec` gives each workflow its own unsigned JWT runtime token and artifact run, so artifact names conflict and carry over between runs as on GitHub. Artifacts default to a temporary directory instead of the workspace that jobs copy.

Co-authored-by: silverwind <[email protected]>
Co-authored-by: Lunny Xiao <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1341
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-28 03:52:53 +00:00
Lunny Xiao a76ccd7020 test(e2e): cover the register/daemon CLI and failing jobs (#1346)
The e2e suite started the runner in-process and never exercised failing jobs. This adds a scenario that builds the binary and drives `register` then `daemon` against the Gitea fixture, and one for failed, skipped, continue-on-error and timed-out jobs.

Verified locally against gitea/gitea:main-nightly.

Assisted-by: Codet:codet
Reviewed-on: https://gitea.com/gitea/runner/pulls/1346
Reviewed-by: Zettat123 <[email protected]>
2026-10-27 21:23:59 +00:00
3dbed63f55 fix: close the action header group before the action runs (#1345)
`uses:` steps and job hooks kept their "Run …" group open until they finished. Gitea doesn't nest groups, so the action's own first `::group::` closed the header and everything before it stayed hidden there, e.g. all of goreleaser's output in https://gitea.com/gitea/cli/actions/runs/1121069/jobs/1519275. The header group now closes right after its details, as in actions/runner.

The action header also matches actions/runner's otherwise now: it is titled by the `uses:` reference, lists the resolved inputs including `action.yml` defaults without empty ones, is shown for pre stages too, and post stages start with `Post job cleanup.`.

---------

Co-authored-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1345
Reviewed-by: silverwind <[email protected]>
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-10-26 17:42:16 +00:00
silverwindandbircni dafe8d8d0c fix: keep dry runs off the docker daemon and speed up the tests (#1349)
Without a docker daemon six `act/runner` tests failed instead of passing. A dry run still created and removed job networks, the job teardown dropped the dry-run flag along with every other context value, and the container mocks asked the daemon for the runner arch.

This also speeds up the tests and CI without dropping assertions. Most of the time went to git and docker round trips, which are now pinned, batched or coalesced, and the dind and e2e steps cache or overlap more of their setup. On CI, `act/runner` went from 77s to 55s and `test-dind` from 108s to 55s. Locally, `act/runner` without docker went from 53s to 7s.

Split out of https://gitea.com/gitea/runner/pulls/1348, which adds Windows CI on top. The go cache changes moved to https://gitea.com/gitea/runner/pulls/1350.

Written by Claude Code.

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1349
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-26 17:25:17 +00:00
Zettat123 8ae48ae033 fix: keep the shared action cache as extracted, re-fetch damaged trees (#1352)
The toolkit patch rewrote bundles in place in the shared action cache. Since https://gitea.com/gitea/runner/pulls/1316 trees are never refreshed, so a bundle left truncated failed every later job using it.

Patched bundles now only overwrite the job container's copy, so the runner no longer writes to an extracted tree.

Extraction records each file's size beside the tree, and a tree that no longer matches is fetched again, offline mode included, as a missing one always was. This also catches damage from outside the runner, such as storage losing a write, for a stat per file, a fraction of the copy each stage already makes. Trees from older runners have no record and download once more, except in offline mode, which keeps using them as before.

Generated with Claude Code.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1352
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: bircni <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-10-26 17:20:53 +00:00
Renovate Bot 8dd3776014 chore(deps): update dependencies (#1351)
Co-authored-by: Renovate Bot <[email protected]>
2026-10-26 07:06:33 +00:00
silverwind c6ca020281 ci: implement gitea's cache-seeder (#1350)
Like Gitea's CI, only a `cache-seeder` workflow on main saves the go caches, all other jobs only restore them. Caches are scoped per repository, so until now any pull request, forks included, could write a cache that other runs restore. The seeder compiles the tests without running them, so no test result is ever cached.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1350
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-24 17:04:51 +00:00
silverwind fe2a66dc25 fix: align runner behaviours (#1338)
Contains a number of small bug fixes where jobs behaved differently from GitHub or failed silently.

1. Hooks fail on a refused `::set-env::` or `::add-path::` and no longer inherit the last step's identity
1. Docker build, exec and wait errors are returned instead of swallowed, token-only registry credentials work, logs of `--tty` services decode
1. Env files split lines like GitHub on each OS, so a carriage return is kept on Linux and CRLF ends a line on Windows, empty heredoc names or delimiters are rejected
1. Post steps run after their pre step even when main was skipped, composites see undeclared inputs, `runs.args` wins without `with.args` being parsed
1. Docker actions skip evaluating defaults of inputs given in `with` and read `entryPoint` and `args` in any case, a lowercase `dockerfile` is found, undeclared inputs get GitHub's warning
1. A user-set `CI` is kept, `GITHUB_REPOSITORY_ID` and the other missing `GITHUB_*` variables are exported
1. `timeout-minutes` can read the step's own `env`, a step-level `ACTIONS_ALLOW_UNSECURE_COMMANDS: false` beats a job-level `true` while the runner's own opt-in still wins, `pwsh` quotes the script path
1. A step `env:` given as one expression such as `${{ fromJSON(matrix.env) }}` is applied, `hashFiles` fails with GitHub's messages on script errors and after 120 seconds, a job image without node still gets an empty hash
1. A step whose command files cannot be reset fails instead of importing stale ones, a timeout between steps fails the job instead of reporting success
1. One named volume can be mounted at several paths, host-mode PTY steps keep their last output when they fail, host scratch directories are removed when job setup fails
1. Cancelling a step right after it starts kills its whole process tree
1. Cache prefix restores pick the newest entry created within the same second, also on S3, cache commits with a wrong size are rejected
1. Artifact chunks are written at their offset, download URLs are escaped, listing a run without artifacts no longer crashes
1. Poll backoff reaches `fetch_interval_max`, proxy passwords are masked in more forms, secrets are also masked in step names and in their quoted form in the runner log, a failed final report is logged
1. On Windows hosts a differently cased unsecure-commands opt-in or proxy variable counts, and an early cancel no longer leaks a job object handle
1. Interactive `register` prompts for a missing token and stops on SIGTERM, `exec --list --detect-event` no longer panics without events, `config set` works on `cache.s3` keys

Reviewed-on: https://gitea.com/gitea/runner/pulls/1338
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-23 09:18:24 +00:00
Zettat123 c719a88f36 fix(k8s): retry the startup exec until the kubelet accepts it (#1337)
A new node can run pods before its kubelet has a serving certificate, so the first exec into the job container fails with `error dialing backend: remote error: tls: internal error` and the job fails during setup.

That exec only reads the arch and PATH, so it now retries for up to a minute unless the API server rejects it (4xx) or the command ran.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1337
Reviewed-by: bircni <[email protected]>
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: Zettat123 <[email protected]>
2026-10-22 20:59:48 +00:00
silverwind d882311ec6 refactor: remove dead code and consolidate tests (#1335)
Removes dead code and redundant logic and consolidates tests, so there is less to read and maintain. No behavior change.

1. **act/runner**: inline single-use wrappers, compile regexes once instead of per call, embed the trampoline as a string, drop the unused legacy result of `parseActionCommand`
2. **act/container**: `sanitizeConfig` and `mergeContainerConfigs` stop returning the configs they already mutate in place, drop never-set option fields and the unused `user` parameter of `ExecWithCmdLine`
3. **act/common and cache server**: drop the unused `Trace`/`Tracef` loggers and the short SHA of `FindGitRevision`, `RegisterJob` releases through `RevokeJob`
4. **daemon, poller and reporter**: one shutdown wait for both poll modes, drop an unreachable fetch timeout branch, guard the reporter's last report time with its existing lock instead of an atomic
5. **build**: drop unused Makefile and release workflow variables, collapse the goreleaser ignore list to the same build targets
6. **tests**: fold near-duplicate tests into tables with shared setup

Throughout, hand-rolled logic gives way to `cmp.Or`, `strings.Cut`, `slices` and `min`/`max`.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1335
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-22 19:41:10 +00:00
silverwind 923a0bfb59 refactor: remove bolthold and gofakes3 deps (#1336)
1. Replace bolthold with direct bbolt access in the cache server. bolthold has not changed since March 2024 and kept `encoding/gob` in the binary although the store was already JSON. The on-disk layout stays the same, so existing caches keep working, and the index buckets bolthold left behind are dropped on the first sweep. Queries now run against an in-memory copy of the entries that is only re-read after another process wrote to the store, so lookups and sweeps no longer decode the whole store.

2. Replace gofakes3 with a small in-memory S3 fake in the one test that used it, which removes aws-sdk-go-v2, afero, mgo and x/tools from the module graph.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1336
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-22 16:28:49 +00:00
silverwind 6f0d4f1b48 refactor: migrate to connect-go v2 (#1325)
Migrates the runner to `connectrpc.com/connect/v2` by pinning actionslib v1.5.0, which carries https://gitea.com/gitea/actionslib/pulls/28. The wire behaviour towards Gitea is unchanged and works against Gitea servers on connect v1 and v2. The client keeps v1's unlimited response size, and the error codes and messages the runner checks stay the same.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1325
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-21 16:10:58 +00:00
silverwindandbircni 430f7bb2cf fix: handle workflow commands like GitHub's runner (#1333)
Commands the runner applies itself, like the `::set-output::` older actions still print, showed up raw in the job log. The reporter also re-parsed every line with one job-wide stop-commands and echo state, while act keeps one command handler per step, stage, composite child and hook, as GitHub does. A `::stop-commands::` left active in one of them kept the reporter suppressed for the others, so their `::add-mask::` values were never registered, and post steps logged command lines twice.

- act's command handler decides what reaches the log, as GitHub's runner does: applied commands only under `ACTIONS_STEP_DEBUG` or `::echo::on`, `::stop-commands::` and its resume line shown with long tokens masked
- Masks go straight to the reporter, which no longer parses command lines
- One parser follows GitHub's rules: leading whitespace, `##[...]` anywhere in a line, registered commands only
- Service logs honour only `::add-mask::`

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1333
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-21 16:03:40 +00:00
silverwind befb1b4bb9 fix: improve the actions toolkit patching (#1328)
Patch the scripts beside each node action entrypoint too, so code-split bundles like rust-cache v2.9.2, setup-java v6 and setup-deno v2 reach the v2 cache API. Entrypoints may point anywhere in the action's repository, as `gradle/actions/setup-gradle` does with `../dist`.

The `.LOCALHOST` match is now anchored on the `.GHE.COM` test before it, so another library's uppercase localhost check is never rewritten.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1328
Reviewed-by: Lunny Xiao <[email protected]>
2026-10-21 05:26:32 +00:00
silverwind 1a7f09224f perf: speed up job setup, action preparation and the cache server (#1327)
- Look up containers by name instead of listing all of them
- On Linux, cache the docker proxy discovery instead of starting a probe container per job
- Download a job's actions concurrently and share identical fetches between concurrent jobs
- Cache `runner.arch` per run and poll service health every 250ms
- Cache server: queue on an in-process lock instead of polling the database file lock, upload S3 objects up to 64MiB in one request and larger ones in 4 concurrent parts

| `exec`, median of 5 | Before | After |
|---|---:|---:|
| 1 step | 1.70s | 0.87s |
| 10 steps | 2.84s | 1.97s |
| Service + step | 3.78s | 2.18s |
| checkout + Node action | 3.64s | 2.52s |

Reviewed-on: https://gitea.com/gitea/runner/pulls/1327
Reviewed-by: Zettat123 <[email protected]>
2026-10-21 05:12:54 +00:00
192 changed files with 8348 additions and 8625 deletions

No files matched your search

+5
View File
@@ -45,8 +45,13 @@ cpu.out
/bin /bin
/dist /dist
/.tmp
/.git/hooks
/.git/logs
/.git/rr-cache
/.env /.env
/.runner /.runner
/.runner.lock
/config.yaml /config.yaml
/Dockerfile /Dockerfile
.DS_Store .DS_Store
+29
View File
@@ -0,0 +1,29 @@
name: go-setup
description: Set up go and restore its caches, which only the cache-seeder workflow saves
runs:
using: composite
steps:
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
id: setup-go
with:
go-version-file: go.mod
check-latest: true
cache: false
- if: ${{ github.workflow == 'cache-seeder' }}
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
~/.cache/golangci-lint
key: go-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('go.mod', 'go.sum', '.golangci.yml', 'Makefile', '.gitea/actions/go-setup/action.yml', '.gitea/workflows/cache-seeder.yml') }}
- if: ${{ github.workflow != 'cache-seeder' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
~/.cache/golangci-lint
key: go-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('go.mod', 'go.sum', '.golangci.yml', 'Makefile', '.gitea/actions/go-setup/action.yml', '.gitea/workflows/cache-seeder.yml') }}
restore-keys: go-${{ runner.os }}-${{ runner.arch }}-${{ steps.setup-go.outputs.go-version }}-
+26
View File
@@ -0,0 +1,26 @@
name: cache-seeder
on:
workflow_dispatch:
push:
branches:
- main
paths:
- go.mod
- go.sum
- .golangci.yml
- Makefile
- .gitea/actions/go-setup/action.yml
- .gitea/workflows/cache-seeder.yml
jobs:
seed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: ./.gitea/actions/go-setup
- run: make deps-tools lint build
- name: compile the test binaries without running them
run: |
make test GOTEST_FLAGS='-race -exec true'
go test -tags e2e -exec true ./e2e/...
+2 -3
View File
@@ -11,7 +11,6 @@ on:
env: env:
DOCKER_ORG: gitea DOCKER_ORG: gitea
DOCKER_LATEST: nightly
jobs: jobs:
goreleaser: goreleaser:
@@ -36,7 +35,7 @@ jobs:
go-version-file: "go.mod" go-version-file: "go.mod"
check-latest: true check-latest: true
- name: goreleaser - name: goreleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
args: release --nightly args: release --nightly
@@ -80,7 +79,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
+2 -3
View File
@@ -35,7 +35,7 @@ jobs:
passphrase: ${{ secrets.PASSPHRASE }} passphrase: ${{ secrets.PASSPHRASE }}
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0 fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
- name: goreleaser - name: goreleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
args: release args: release
@@ -69,7 +69,6 @@ jobs:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
env: env:
DOCKER_ORG: gitea DOCKER_ORG: gitea
DOCKER_LATEST: latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -83,7 +82,7 @@ jobs:
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
+11 -15
View File
@@ -17,25 +17,24 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - uses: ./.gitea/actions/go-setup
with:
go-version-file: 'go.mod'
check-latest: true
- name: prepare anonymous docker config - name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json" run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`; # Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host # the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
# daemon retains them between runs, so this is usually a fast manifest re-check. # daemon retains them between runs, and their digest pins skip the registry when it has them.
- name: pre-pull test images - name: pre-pull test images
env: env:
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20 # renovate: datasource=docker TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # renovate: datasource=docker TEST_SERVICE_IMAGE: nginx:alpine@sha256:a3554c0d0b48177b1413c26e01f695334428020d9df01243fbae3e11cfd17417 # 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 if [ "$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null)" != linux/amd64 ]; then
docker pull "$image" && break for attempt in 1 2 3; do
[ "$attempt" = 3 ] || sleep 5 docker pull "$image" && break
done [ "$attempt" = 3 ] || sleep 5
done
fi
docker tag "$image" "${image%@*}" docker tag "$image" "${image%@*}"
done done
- name: lint - name: lint
@@ -68,10 +67,7 @@ jobs:
- gitea/gitea:main-nightly - gitea/gitea:main-nightly
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 - uses: ./.gitea/actions/go-setup
with:
go-version-file: 'go.mod'
check-latest: true
- name: prepare anonymous docker config - name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json" run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
- name: e2e compatibility - name: e2e compatibility
-20
View File
@@ -28,38 +28,18 @@ builds:
ignore: ignore:
- goos: darwin - goos: darwin
goarch: arm goarch: arm
- goos: darwin
goarch: ppc64le
- goos: darwin - goos: darwin
goarch: s390x goarch: s390x
- goos: windows
goarch: ppc64le
- goos: windows - goos: windows
goarch: s390x goarch: s390x
- goos: windows - goos: windows
goarch: arm goarch: arm
goarm: "5"
- goos: windows
goarch: arm
goarm: "6"
- goos: windows
goarch: arm
goarm: "7"
- goos: windows - goos: windows
goarch: arm64 goarch: arm64
- goos: freebsd
goarch: ppc64le
- goos: freebsd - goos: freebsd
goarch: s390x goarch: s390x
- goos: freebsd - goos: freebsd
goarch: arm goarch: arm
goarm: "5"
- goos: freebsd
goarch: arm
goarm: "6"
- goos: freebsd
goarch: arm
goarm: "7"
- goos: freebsd - goos: freebsd
goarch: arm64 goarch: arm64
flags: flags:
+7 -2
View File
@@ -28,8 +28,8 @@ depending on the prefix:
- **No prefix?** Do not escape, and split multi-line values into one row each. - **No prefix?** Do not escape, and split multi-line values into one row each.
- **Interpolating a secret?** Masking runs after escaping, so `AppendSecretMasker` registers the - **Interpolating a secret?** Masking runs after escaping, so `AppendSecretMasker` registers the
encoded forms too. encoded forms too.
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter - Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command
decodes exactly those two when folding a location into an annotation. handler folds an annotation's location into its decoded message and escapes the result again.
## Logging ## Logging
@@ -59,5 +59,10 @@ Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions`
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
parallel. parallel.
The `cli_*` scenarios build the binary and drive `register` and `daemon` as a user would, including `--once`,
`--labels` and shutdown with a running job. `failure_paths` covers failed, skipped, tolerated and timed-out jobs.
`label_routing` and `capacity` cover scheduling, `reconnect` drops the connection to Gitea through a proxy, and
`host_mode` and `reusable_workflow` cover host labels and `workflow_call`.
The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`. repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
+3 -1
View File
@@ -12,7 +12,9 @@ ENV GOPROXY=${GOPROXY:-}
COPY . /opt/src/runner COPY . /opt/src/runner
WORKDIR /opt/src/runner WORKDIR /opt/src/runner
RUN make clean && make build RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
make clean && make build
### DIND VARIANT ### DIND VARIANT
# #
+3 -7
View File
@@ -4,7 +4,7 @@ GO ?= go
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" ) HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:0e62e56b382ebf485bff1e51a05cbe27c708545a2e91425467fd31eb3e249521 # renovate: datasource=docker E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:8856b200466e9a5af9cc0d4de4ca96f73547833bcdddf9a567189ff772c4d372 # renovate: datasource=docker
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker
@@ -14,10 +14,9 @@ GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "genera
DOCKER_IMAGE ?= gitea/runner DOCKER_IMAGE ?= gitea/runner
DOCKER_TAG ?= nightly DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG) DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]4.0 # renovate: datasource=go GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]5.0 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.9.0 # renovate: datasource=go
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8 GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
@@ -34,15 +33,12 @@ endif
ifeq ($(OS), Windows_NT) ifeq ($(OS), Windows_NT)
GOFLAGS := -v -buildmode=exe GOFLAGS := -v -buildmode=exe
EXECUTABLE ?= $(EXECUTABLE).exe
GO_ENV_WINDOWS := set GOOS=windows&& GO_ENV_WINDOWS := set GOOS=windows&&
else ifeq ($(OS), Windows) else ifeq ($(OS), Windows)
GOFLAGS := -v -buildmode=exe GOFLAGS := -v -buildmode=exe
EXECUTABLE ?= $(EXECUTABLE).exe
GO_ENV_WINDOWS := set GOOS=windows&& GO_ENV_WINDOWS := set GOOS=windows&&
else else
GOFLAGS := -v GOFLAGS := -v
EXECUTABLE ?= $(EXECUTABLE)
GO_ENV_WINDOWS := GOOS=windows GO_ENV_WINDOWS := GOOS=windows
endif endif
+298 -149
View File
@@ -15,6 +15,7 @@ import (
"encoding/json/v2" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
@@ -32,8 +33,8 @@ import (
"gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/internal/pkg/disk" "gitea.com/gitea/runner/internal/pkg/disk"
"github.com/timshannon/bolthold"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
berrors "go.etcd.io/bbolt/errors"
) )
const ( const (
@@ -44,6 +45,8 @@ const (
// Short enough that a leaked URL is near-worthless; long enough to let the // Short enough that a leaked URL is near-worthless; long enough to let the
// @actions/cache client download a big blob that was returned from /cache. // @actions/cache client download a big blob that was returned from /cache.
artifactURLTTL = 10 * time.Minute artifactURLTTL = 10 * time.Minute
dbOpenTimeout = 5 * time.Second
) )
type credKey struct{} type credKey struct{}
@@ -79,12 +82,14 @@ type Handler struct {
dir string dir string
storage cacheStorage storage cacheStorage
s3 *s3Storage s3 *s3Storage
router *http.ServeMux
listener net.Listener listener net.Listener
port int port int
server *http.Server server *http.Server
logger *logging.Logger logger *logging.Logger
dbLock chan struct{} // queues store openers without polling bolt's file lock
entries cacheEntries // guarded by dbLock
gcing atomic.Bool gcing atomic.Bool
gcAt time.Time gcAt time.Time
@@ -142,6 +147,7 @@ func StartHandler(opts Options) (*Handler, error) {
internalSecret: opts.InternalSecret, internalSecret: opts.InternalSecret,
policy: opts.Policy.withDefaults(), policy: opts.Policy.withDefaults(),
freeDisk: disk.FreeBytes, freeDisk: disk.FreeBytes,
dbLock: make(chan struct{}, 1),
} }
if logger == nil { if logger == nil {
@@ -194,11 +200,9 @@ func StartHandler(opts Options) (*Handler, error) {
h.storage = h.s3 h.storage = h.s3
} }
secret, err := loadOrCreateSecret(dir) if h.secret, err = loadOrCreateSecret(dir); err != nil {
if err != nil {
return nil, err return nil, err
} }
h.secret = secret
router := http.NewServeMux() router := http.NewServeMux()
router.HandleFunc("GET "+apiPath+"/cache", h.bearerAuth(h.find)) router.HandleFunc("GET "+apiPath+"/cache", h.bearerAuth(h.find))
@@ -217,8 +221,6 @@ func StartHandler(opts Options) (*Handler, error) {
h.registerV2Routes(router) h.registerV2Routes(router)
router.HandleFunc(artifactServicePath, h.forwardOrNotFound) router.HandleFunc(artifactServicePath, h.forwardOrNotFound)
h.router = router
h.gcCache() h.gcCache()
if err := h.serve(opts.Port, router); err != nil { if err := h.serve(opts.Port, router); err != nil {
@@ -327,16 +329,7 @@ func (h *Handler) RegisterJob(token string, cred JobCredential) func() {
} }
} }
h.credMu.Unlock() h.credMu.Unlock()
return func() { return func() { h.RevokeJob(token) }
h.credMu.Lock()
if entry, ok := h.creds[token]; ok {
entry.refs--
if entry.refs <= 0 {
delete(h.creds, token)
}
}
h.credMu.Unlock()
}
} }
// RevokeJob explicitly revokes one registration of token, mirroring one call // RevokeJob explicitly revokes one registration of token, mirroring one call
@@ -399,10 +392,7 @@ func (h *Handler) Close() error {
} }
var retErr error var retErr error
if h.server != nil { if h.server != nil {
err := h.server.Close() retErr = h.server.Close()
if err != nil {
retErr = err
}
h.server = nil h.server = nil
} }
if h.listener != nil { if h.listener != nil {
@@ -418,16 +408,170 @@ func (h *Handler) Close() error {
return retErr return retErr
} }
func (h *Handler) openDB() (*bolthold.Store, error) { // cacheBucket holds each entry as JSON under its decimal ID, the layout bolthold wrote, so older stores stay readable.
return bolthold.Open(filepath.Join(h.dir, "bolt.db"), 0o644, &bolthold.Options{ var cacheBucket = []byte("Cache")
Encoder: func(value any) ([]byte, error) { return json.Marshal(value) },
Decoder: func(data []byte, value any) error { return json.Unmarshal(data, value) }, var errCacheNotFound = errors.New("cache not found")
Options: &bbolt.Options{
Timeout: 5 * time.Second, type cacheDB struct {
NoGrowSync: bbolt.DefaultOptions.NoGrowSync, bolt *bbolt.DB
FreelistType: bbolt.DefaultOptions.FreelistType, close func() error
}, entries *cacheEntries
}
// cacheEntries mirrors the bucket as of transaction tx, so queries decode it again only after another process wrote.
type cacheEntries struct {
tx int
byID map[uint64]*Cache
}
func (db *cacheDB) Close() error {
return db.close()
}
func cacheKey(id uint64) []byte {
return strconv.AppendUint(nil, id, 10)
}
func (db *cacheDB) get(id uint64) (*Cache, error) {
if err := db.bolt.View(db.load); err != nil {
return nil, err
}
cache, ok := db.entries.byID[id]
if !ok {
return nil, errCacheNotFound
}
copied := *cache
return &copied, nil
}
// find returns copies of the matching entries in ID order, so ties break the same way every time.
func (db *cacheDB) find(match func(*Cache) bool) ([]*Cache, error) {
if err := db.bolt.View(db.load); err != nil {
return nil, err
}
var caches []*Cache
for _, cache := range db.entries.byID {
if match(cache) {
copied := *cache
caches = append(caches, &copied)
}
}
slices.SortFunc(caches, func(left, right *Cache) int { return cmp.Compare(left.ID, right.ID) })
return caches, nil
}
func (db *cacheDB) load(tx *bbolt.Tx) error {
if db.entries.byID != nil && db.entries.tx == tx.ID() {
return nil
}
byID := make(map[uint64]*Cache)
if bucket := tx.Bucket(cacheBucket); bucket != nil {
if err := bucket.ForEach(func(key, data []byte) (err error) {
cache := &Cache{}
if err := json.Unmarshal(data, cache); err != nil {
return err
}
cache.ID, err = strconv.ParseUint(string(key), 10, 64) // an interrupted legacy insert left "id":0 in the value
byID[cache.ID] = cache
return err
}); err != nil {
return err
}
}
db.entries.byID, db.entries.tx = byID, tx.ID()
return nil
}
// update keeps the mirror in step with its own write, and drops it when another process wrote in between or the write rolled back.
func (db *cacheDB) update(fn func(*bbolt.Bucket) error) error {
err := db.bolt.Update(func(tx *bbolt.Tx) error {
if db.entries.tx != tx.ID()-1 {
db.entries.byID = nil
}
db.entries.tx = tx.ID()
bucket, err := tx.CreateBucketIfNotExists(cacheBucket)
if err != nil {
return err
}
return fn(bucket)
}) })
if err != nil {
db.entries.byID = nil
}
return err
}
func (db *cacheDB) put(bucket *bbolt.Bucket, cache *Cache) error {
data, err := json.Marshal(cache)
if err != nil {
return err
}
if db.entries.byID != nil {
copied := *cache
db.entries.byID[cache.ID] = &copied
}
return bucket.Put(cacheKey(cache.ID), data)
}
func (db *cacheDB) delete(bucket *bbolt.Bucket, id uint64) error {
delete(db.entries.byID, id)
return bucket.Delete(cacheKey(id))
}
// dropLegacyIndexes deletes the index buckets bolthold kept, all created together and never recreated, checking first so later runs do not write.
func (db *cacheDB) dropLegacyIndexes() error {
var found bool
_ = db.bolt.View(func(tx *bbolt.Tx) error {
found = tx.Bucket([]byte("_index:Cache:Key")) != nil
return nil
})
if !found {
return nil
}
return db.bolt.Update(func(tx *bbolt.Tx) error {
for _, field := range []string{"Repo", "Key", "Version", "Complete", "UsedAt", "CreatedAt"} {
if err := tx.DeleteBucket([]byte("_index:Cache:" + field)); err != nil && !errors.Is(err, berrors.ErrBucketNotFound) {
return err
}
}
return nil
})
}
// save rewrites a stored entry, failing once a sweep has removed it so a late commit cannot resurrect it.
func (db *cacheDB) save(cache *Cache) error {
return db.update(func(bucket *bbolt.Bucket) error {
if bucket.Get(cacheKey(cache.ID)) == nil {
return errCacheNotFound
}
return db.put(bucket, cache)
})
}
func (h *Handler) openDB() (store *cacheDB, err error) {
select {
case h.dbLock <- struct{}{}:
case <-time.After(dbOpenTimeout):
return nil, berrors.ErrTimeout
}
defer func() {
if store == nil { // only an opened store hands the lock to Close, even when Open panics
<-h.dbLock
}
}()
db, err := bbolt.Open(filepath.Join(h.dir, "bolt.db"), 0o644, &bbolt.Options{
Timeout: dbOpenTimeout,
NoGrowSync: bbolt.DefaultOptions.NoGrowSync,
FreelistType: bbolt.DefaultOptions.FreelistType,
})
if err != nil {
return nil, err
}
return &cacheDB{bolt: db, entries: &h.entries, close: sync.OnceValue(func() error {
defer func() { <-h.dbLock }()
return db.Close()
})}, nil
} }
// syncMetadata mirrors the bucket into the store until ctx ends, so no request waits on S3 for it. // syncMetadata mirrors the bucket into the store until ctx ends, so no request waits on S3 for it.
@@ -452,7 +596,7 @@ func (h *Handler) importMetadata(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
local := h.caches(db, bolthold.Where("Complete").Eq(true).Index("Complete")) local := h.caches(db, func(cache *Cache) bool { return cache.Complete })
db.Close() db.Close()
published, err := h.s3.published(ctx) published, err := h.s3.published(ctx)
@@ -480,15 +624,15 @@ func (h *Handler) importMetadata(ctx context.Context) error {
return err return err
} }
defer db.Close() defer db.Close()
return db.Bolt().Update(func(tx *bbolt.Tx) error { return db.update(func(bucket *bbolt.Bucket) error {
for _, cache := range added { for _, cache := range added {
if err := db.TxUpsert(tx, cache.ID, cache); err != nil { if err := db.put(bucket, cache); err != nil {
return err return err
} }
} }
for _, cache := range gone { for _, cache := range gone {
_ = h.s3.Storage.Remove(cache.ID) // a blob kept on disk before the runner used S3 _ = h.s3.Storage.Remove(cache.ID) // a blob kept on disk before the runner used S3
if err := db.TxDelete(tx, cache.ID, cache); err != nil && !errors.Is(err, bolthold.ErrNotFound) { if err := db.delete(bucket, cache.ID); err != nil {
return err return err
} }
} }
@@ -528,7 +672,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request) {
// lookupCache returns the entry to restore for these keys, or (nil, nil) when there is none: // lookupCache returns the entry to restore for these keys, or (nil, nil) when there is none:
// either nothing matched, or the match had lost its blob to a prune, in which case the dangling // either nothing matched, or the match had lost its blob to a prune, in which case the dangling
// entry is dropped on the way out. // entry is dropped on the way out.
func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) { func (h *Handler) lookupCache(db *cacheDB, repo string, keys []string, version string) (*Cache, error) {
cache, err := findCache(db, repo, keys, version) cache, err := findCache(db, repo, keys, version)
if err != nil || cache == nil { if err != nil || cache == nil {
return nil, err return nil, err
@@ -567,9 +711,10 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request) {
} }
defer db.Close() defer db.Close()
now := time.Now().Unix() now := time.Now()
cache.CreatedAt = now cache.CreatedAt = now.Unix()
cache.UsedAt = now cache.CreatedAtNanos = now.Nanosecond()
cache.UsedAt = now.Unix()
if err := h.insertCache(db, cache); err != nil { if err := h.insertCache(db, cache); err != nil {
h.responseJSON(w, r, 500, err) h.responseJSON(w, r, 500, err)
return return
@@ -588,15 +733,15 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
return return
} }
cache := &Cache{}
db, err := h.openDB() db, err := h.openDB()
if err != nil { if err != nil {
h.responseJSON(w, r, 500, err) h.responseJSON(w, r, 500, err)
return return
} }
defer db.Close() defer db.Close()
if err := db.Get(id, cache); err != nil { cache, err := db.get(uint64(id))
if errors.Is(err, bolthold.ErrNotFound) { if err != nil {
if errors.Is(err, errCacheNotFound) {
h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id)) h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
return return
} }
@@ -614,7 +759,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
return return
} }
db.Close() db.Close()
start, _, err := parseContentRange(r.Header.Get("Content-Range")) start, err := parseContentRange(r.Header.Get("Content-Range"))
if err != nil { if err != nil {
h.responseJSON(w, r, 400, err) h.responseJSON(w, r, 400, err)
return return
@@ -635,16 +780,27 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request) {
h.responseJSON(w, r, 400, err) h.responseJSON(w, r, 400, err)
return return
} }
var request struct {
Size *int64 `json:"size"`
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err == nil && len(body) > 0 {
err = json.Unmarshal(body, &request)
}
if err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
cache := &Cache{}
db, err := h.openDB() db, err := h.openDB()
if err != nil { if err != nil {
h.responseJSON(w, r, 500, err) h.responseJSON(w, r, 500, err)
return return
} }
defer db.Close() defer db.Close()
if err := db.Get(id, cache); err != nil { cache, err := db.get(uint64(id))
if errors.Is(err, bolthold.ErrNotFound) { if err != nil {
if errors.Is(err, errCacheNotFound) {
h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id)) h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
return return
} }
@@ -662,6 +818,14 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request) {
return return
} }
if request.Size != nil {
if *request.Size < 0 || cache.Size >= 0 && cache.Size != *request.Size {
h.responseJSON(w, r, http.StatusBadRequest, errors.New("invalid cache size"))
return
}
cache.Size = *request.Size
}
db.Close() db.Close()
if err := h.commitCache(cache); err != nil { if err := h.commitCache(cache); err != nil {
@@ -696,7 +860,7 @@ func (h *Handler) commitCache(cache *Cache) error {
return err return err
} }
defer db.Close() defer db.Close()
if err := db.Update(cache.ID, cache); err != nil { if err := db.save(cache); err != nil {
return err return err
} }
if h.s3 != nil { if h.s3 != nil {
@@ -881,10 +1045,8 @@ func bearerToken(r *http.Request) string {
} }
func credFromContext(ctx context.Context) JobCredential { func credFromContext(ctx context.Context) JobCredential {
if cred, ok := ctx.Value(credKey{}).(JobCredential); ok { cred, _ := ctx.Value(credKey{}).(JobCredential)
return cred return cred
}
return JobCredential{}
} }
// computeSignature signs a URL for one cache entry and expiry. purpose is mixed into the // computeSignature signs a URL for one cache entry and expiry. purpose is mixed into the
@@ -910,8 +1072,7 @@ func (h *Handler) signedArtifactURL(cred JobCredential, cacheID uint64, exp time
} }
// if not found, return (nil, nil) instead of an error. // if not found, return (nil, nil) instead of an error.
func findCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) { func findCache(db *cacheDB, repo string, keys []string, version string) (*Cache, error) {
cache := &Cache{}
for _, prefix := range keys { for _, prefix := range keys {
// if a key in the list matches exactly, don't return partial matches // if a key in the list matches exactly, don't return partial matches
exact, err := findExactCache(db, repo, prefix, version, true) exact, err := findExactCache(db, repo, prefix, version, true)
@@ -921,22 +1082,28 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
if exact != nil { if exact != nil {
return exact, nil return exact, nil
} }
if err := db.FindOne(cache, caches, err := db.find(func(cache *Cache) bool {
bolthold.Where("Repo").Eq(repo).Index("Repo"). return cache.Repo == repo && strings.HasPrefix(cache.Key, prefix) && cache.Version == version && cache.Complete
And("Key").MatchFunc(func(key string) (bool, error) { return strings.HasPrefix(key, prefix), nil }). })
And("Version").Eq(version). if err != nil {
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err != nil {
if errors.Is(err, bolthold.ErrNotFound) {
continue
}
return nil, fmt.Errorf("find cache: %w", err) return nil, fmt.Errorf("find cache: %w", err)
} }
return cache, nil if cache := latest(caches, (*Cache).createdAtNano); cache != nil {
return cache, nil
}
} }
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
} }
func latest(caches []*Cache, stamp func(*Cache) int64) *Cache {
if len(caches) == 0 {
return nil
}
return slices.MaxFunc(caches, func(left, right *Cache) int {
return cmp.Or(cmp.Compare(stamp(left), stamp(right)), cmp.Compare(left.ID, right.ID))
})
}
// findExactCache returns the entry for exactly this key and version, or (nil, nil) if there is // findExactCache returns the entry for exactly this key and version, or (nil, nil) if there is
// none. Unlike findCache it never falls back to a prefix (restore-key) match, which is what both // none. Unlike findCache it never falls back to a prefix (restore-key) match, which is what both
// its callers need: a new key that is only a prefix of an existing key is not the same entry. // its callers need: a new key that is only a prefix of an existing key is not the same entry.
@@ -944,43 +1111,33 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
// A completed entry is the one to restore, sorted by when it was written. An incomplete one is a // A completed entry is the one to restore, sorted by when it was written. An incomplete one is a
// reservation being uploaded to, sorted by when it was last written to, because the upload route // reservation being uploaded to, sorted by when it was last written to, because the upload route
// touches UsedAt on every part. // touches UsedAt on every part.
func findExactCache(db *bolthold.Store, repo, key, version string, complete bool) (*Cache, error) { func findExactCache(db *cacheDB, repo, key, version string, complete bool) (*Cache, error) {
sortBy := "UsedAt" caches, err := db.find(func(cache *Cache) bool {
if complete { return cache.Key == key && cache.Repo == repo && cache.Version == version && cache.Complete == complete
sortBy = "CreatedAt" })
}
cache := &Cache{}
err := db.FindOne(cache,
bolthold.Where("Key").Eq(key).Index("Key").
And("Repo").Eq(repo).
And("Version").Eq(version).
And("Complete").Eq(complete).
SortBy(sortBy).Reverse())
if errors.Is(err, bolthold.ErrNotFound) {
return nil, nil //nolint:nilnil // absence is not an error here
}
if err != nil { if err != nil {
return nil, fmt.Errorf("find cache: %w", err) return nil, fmt.Errorf("find cache: %w", err)
} }
return cache, nil stamp := func(cache *Cache) int64 { return cache.UsedAt }
if complete {
stamp = (*Cache).createdAtNano
}
return latest(caches, stamp), nil
} }
func (h *Handler) insertCache(db *bolthold.Store, cache *Cache) error { func (h *Handler) insertCache(db *cacheDB, cache *Cache) error {
if h.s3 != nil { return db.update(func(bucket *bbolt.Bucket) (err error) {
var id [8]byte if h.s3 != nil {
_, _ = rand.Read(id[:]) var id [8]byte
cache.ID = binary.BigEndian.Uint64(id[:]) >> 11 // IDs must be unique across runners, and v1 clients parse an ID as a JS number _, _ = rand.Read(id[:])
return db.Insert(cache.ID, cache) cache.ID = binary.BigEndian.Uint64(id[:]) >> 11 // IDs must be unique across runners, and v1 clients parse an ID as a JS number
} } else if cache.ID, err = bucket.NextSequence(); err != nil {
return db.Bolt().Update(func(tx *bbolt.Tx) error {
if err := db.TxInsert(tx, bolthold.NextSequence(), cache); err != nil {
return fmt.Errorf("insert cache: %w", err) return fmt.Errorf("insert cache: %w", err)
} }
// write back id to db if bucket.Get(cacheKey(cache.ID)) != nil {
if err := db.TxUpdate(tx, cache.ID, cache); err != nil { return fmt.Errorf("insert cache: id %d is taken", cache.ID)
return fmt.Errorf("write back id to db: %w", err)
} }
return nil return db.put(bucket, cache)
}) })
} }
@@ -996,11 +1153,11 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
} }
defer db.Close() defer db.Close()
cache := &Cache{} cache, err := db.get(id)
if err := db.Get(id, cache); err != nil { if errors.Is(err, errCacheNotFound) {
if errors.Is(err, bolthold.ErrNotFound) { return nil
return nil }
} if err != nil {
return err return err
} }
if requireIncomplete && cache.Complete { if requireIncomplete && cache.Complete {
@@ -1010,7 +1167,7 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
return nil return nil
} }
cache.UsedAt = time.Now().Unix() cache.UsedAt = time.Now().Unix()
return db.Update(cache.ID, cache) return db.save(cache)
} }
const ( const (
@@ -1056,9 +1213,6 @@ func (p Policy) withDefaults() Policy {
} }
func (h *Handler) gcCache() { func (h *Handler) gcCache() {
if h.gcing.Load() {
return
}
if !h.gcing.CompareAndSwap(false, true) { if !h.gcing.CompareAndSwap(false, true) {
return return
} }
@@ -1077,6 +1231,9 @@ func (h *Handler) gcCache() {
} }
defer db.Close() defer db.Close()
if err := db.dropLegacyIndexes(); err != nil {
h.logger.Warnf("drop legacy indexes: %v", err)
}
h.evictIncomplete(db) h.evictIncomplete(db)
if h.s3 != nil { if h.s3 != nil {
return // lifecycle rules on the bucket expire its entries return // lifecycle rules on the bucket expire its entries
@@ -1089,7 +1246,7 @@ func (h *Handler) gcCache() {
// evictForFreeSpace bounds the volume itself, so it also covers bytes the cache never // evictForFreeSpace bounds the volume itself, so it also covers bytes the cache never
// accounted for. // accounted for.
func (h *Handler) evictForFreeSpace(db *bolthold.Store) { func (h *Handler) evictForFreeSpace(db *cacheDB) {
free, err := h.freeDisk(h.dir) free, err := h.freeDisk(h.dir)
if err != nil { if err != nil {
h.logger.Debugf("free disk check: %v", err) // unsupported platform, treat as unavailable rather than full h.logger.Debugf("free disk check: %v", err) // unsupported platform, treat as unavailable rather than full
@@ -1109,38 +1266,33 @@ func (h *Handler) evictForFreeSpace(db *bolthold.Store) {
} }
// evictIncomplete removes uploads that stopped part way, which are most likely broken. // evictIncomplete removes uploads that stopped part way, which are most likely broken.
func (h *Handler) evictIncomplete(db *bolthold.Store) { func (h *Handler) evictIncomplete(db *cacheDB) {
h.sweep(db, bolthold. stalled := time.Now().Add(-uploadStallTimeout).Unix()
Where("UsedAt").Lt(time.Now().Add(-uploadStallTimeout).Unix()). h.sweep(db, func(cache *Cache) bool { return cache.UsedAt < stalled && !cache.Complete })
And("Complete").Eq(false).
Index("UsedAt"))
} }
func (h *Handler) evictExpired(db *bolthold.Store) { func (h *Handler) evictExpired(db *cacheDB) {
if h.policy.Retention <= 0 { if h.policy.Retention <= 0 {
return return
} }
// Never below inUseGrace, or a short retention would outrun a signed URL already issued. // Never below inUseGrace, or a short retention would outrun a signed URL already issued.
window := max(h.policy.Retention+touchStale, inUseGrace) expired := time.Now().Add(-max(h.policy.Retention+touchStale, inUseGrace)).Unix()
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt")) h.sweep(db, func(cache *Cache) bool { return cache.UsedAt < expired })
} }
// evictSuperseded removes entries a newer one with the same key and version replaced. The // evictSuperseded removes entries a newer one with the same key and version replaced. The
// aggregation includes Repo so two repos sharing a (key, version) do not evict each other. // grouping includes Repo so two repos sharing a (key, version) do not evict each other.
func (h *Handler) evictSuperseded(db *bolthold.Store) { func (h *Handler) evictSuperseded(db *cacheDB) {
results, err := db.FindAggregate(&Cache{}, bolthold.Where("Complete").Eq(true).Index("Complete"), "Repo", "Key", "Version") type entry struct{ repo, key, version string }
if err != nil { groups := make(map[entry][]*Cache)
h.logger.Warnf("find aggregate caches: %v", err) for _, cache := range h.caches(db, func(cache *Cache) bool { return cache.Complete }) {
return group := entry{cache.Repo, cache.Key, cache.Version}
groups[group] = append(groups[group], cache)
} }
var caches []*Cache for _, caches := range groups {
for _, result := range results { slices.SortFunc(caches, func(left, right *Cache) int {
if result.Count() <= 1 { return cmp.Or(cmp.Compare(left.createdAtNano(), right.createdAtNano()), cmp.Compare(left.ID, right.ID))
continue })
}
result.Sort("CreatedAt")
caches = caches[:0]
result.Reduction(&caches)
for _, cache := range caches[:len(caches)-1] { for _, cache := range caches[:len(caches)-1] {
if inUse(cache) { if inUse(cache) {
continue continue
@@ -1152,7 +1304,7 @@ func (h *Handler) evictSuperseded(db *bolthold.Store) {
// evictOversized applies the per-repository limit, then the whole-store one. Only completed // evictOversized applies the per-repository limit, then the whole-store one. Only completed
// entries count, since only those carry a size measured at commit rather than claimed. // entries count, since only those carry a size measured at commit rather than claimed.
func (h *Handler) evictOversized(db *bolthold.Store) { func (h *Handler) evictOversized(db *cacheDB) {
if h.policy.RepoSizeLimit > 0 { if h.policy.RepoSizeLimit > 0 {
byRepo := make(map[string][]*Cache) byRepo := make(map[string][]*Cache)
for _, cache := range h.completedByUse(db) { for _, cache := range h.completedByUse(db) {
@@ -1167,7 +1319,7 @@ func (h *Handler) evictOversized(db *bolthold.Store) {
// evictTotal caps the store as a whole. It re-queries because the per-repo pass may have // evictTotal caps the store as a whole. It re-queries because the per-repo pass may have
// deleted rows an earlier result still holds. // deleted rows an earlier result still holds.
func (h *Handler) evictTotal(db *bolthold.Store) { func (h *Handler) evictTotal(db *cacheDB) {
if h.policy.SizeLimit <= 0 { if h.policy.SizeLimit <= 0 {
return return
} }
@@ -1175,15 +1327,15 @@ func (h *Handler) evictTotal(db *bolthold.Store) {
} }
// evictRepo reclaims space when a commit pushes a repo over, rather than at the next sweep. // evictRepo reclaims space when a commit pushes a repo over, rather than at the next sweep.
func (h *Handler) evictRepo(db *bolthold.Store, repo string) { func (h *Handler) evictRepo(db *cacheDB, repo string) {
if h.policy.RepoSizeLimit <= 0 { if h.policy.RepoSizeLimit <= 0 {
return return
} }
h.evictTo(db, h.cachesByUse(db, bolthold.Where("Repo").Eq(repo).And("Complete").Eq(true).Index("Repo")), h.policy.RepoSizeLimit, "repository "+repo) h.evictTo(db, h.cachesByUse(db, func(cache *Cache) bool { return cache.Repo == repo && cache.Complete }), h.policy.RepoSizeLimit, "repository "+repo)
} }
// evictTo deletes until caches fit limit. caches must be ordered by UsedAt ascending. // evictTo deletes until caches fit limit. caches must be ordered by UsedAt ascending.
func (h *Handler) evictTo(db *bolthold.Store, caches []*Cache, limit int64, scope string) { func (h *Handler) evictTo(db *cacheDB, caches []*Cache, limit int64, scope string) {
// An entry bigger than the limit never fits, so it goes on its own account instead of // An entry bigger than the limit never fits, so it goes on its own account instead of
// dragging every neighbour out first and then following them next sweep. // dragging every neighbour out first and then following them next sweep.
fits := caches[:0] fits := caches[:0]
@@ -1230,41 +1382,39 @@ func touchNeeded(cache *Cache) bool {
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot // touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
// go through touchCache, which opens its own store and would block on the exclusive lock // go through touchCache, which opens its own store and would block on the exclusive lock
// for as long as the caller holds one. // for as long as the caller holds one.
func (h *Handler) touch(db *bolthold.Store, cache *Cache) { func (h *Handler) touch(db *cacheDB, cache *Cache) {
if !touchNeeded(cache) { if !touchNeeded(cache) {
return return
} }
cache.UsedAt = time.Now().Unix() cache.UsedAt = time.Now().Unix()
if err := db.Update(cache.ID, cache); err != nil { if err := db.save(cache); err != nil {
h.logger.Warnf("touch cache: %v", err) h.logger.Warnf("touch cache: %v", err)
} }
} }
func (h *Handler) sweep(db *bolthold.Store, query *bolthold.Query) { func (h *Handler) sweep(db *cacheDB, match func(*Cache) bool) {
for _, cache := range h.caches(db, query) { for _, cache := range h.caches(db, match) {
h.deleteCache(db, cache) h.deleteCache(db, cache)
} }
} }
func (h *Handler) caches(db *bolthold.Store, query *bolthold.Query) []*Cache { func (h *Handler) caches(db *cacheDB, match func(*Cache) bool) []*Cache {
var caches []*Cache caches, err := db.find(match)
if err := db.Find(&caches, query); err != nil { if err != nil {
h.logger.Warnf("find caches: %v", err) h.logger.Warnf("find caches: %v", err)
} }
return caches return caches
} }
// cachesByUse returns matches least recently accessed first, sorting here rather than with func (h *Handler) cachesByUse(db *cacheDB, match func(*Cache) bool) []*Cache {
// bolthold's SortBy, which reflects over every field it compares. caches := h.caches(db, match)
func (h *Handler) cachesByUse(db *bolthold.Store, query *bolthold.Query) []*Cache {
caches := h.caches(db, query)
slices.SortFunc(caches, func(a, b *Cache) int { return cmp.Compare(a.UsedAt, b.UsedAt) }) slices.SortFunc(caches, func(a, b *Cache) int { return cmp.Compare(a.UsedAt, b.UsedAt) })
return caches return caches
} }
// completedByUse returns every entry the size limits count, least recently accessed first. // completedByUse returns every entry the size limits count, least recently accessed first.
func (h *Handler) completedByUse(db *bolthold.Store) []*Cache { func (h *Handler) completedByUse(db *cacheDB) []*Cache {
return h.cachesByUse(db, bolthold.Where("Complete").Eq(true).Index("Complete")) return h.cachesByUse(db, func(cache *Cache) bool { return cache.Complete })
} }
func totalSize(caches []*Cache) int64 { func totalSize(caches []*Cache) int64 {
@@ -1277,7 +1427,7 @@ func totalSize(caches []*Cache) int64 {
// deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes // deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes
// first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes. // first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes.
func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool { func (h *Handler) deleteCache(db *cacheDB, cache *Cache) bool {
storage := h.storage storage := h.storage
if h.s3 != nil && !cache.Complete { if h.s3 != nil && !cache.Complete {
storage = h.s3.Storage // an upload not yet committed has nothing in the bucket storage = h.s3.Storage // an upload not yet committed has nothing in the bucket
@@ -1286,7 +1436,7 @@ func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool {
h.logger.Warnf("remove cache blob: %v", err) h.logger.Warnf("remove cache blob: %v", err)
return false return false
} }
if err := db.Delete(cache.ID, cache); err != nil { if err := db.update(func(bucket *bbolt.Bucket) error { return db.delete(bucket, cache.ID) }); err != nil {
h.logger.Warnf("delete cache: %v", err) h.logger.Warnf("delete cache: %v", err)
return false return false
} }
@@ -1311,18 +1461,17 @@ func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int,
_, _ = w.Write(data) _, _ = w.Write(data)
} }
func parseContentRange(s string) (int64, int64, error) { func parseContentRange(s string) (int64, error) {
// support the format like "bytes 11-22/*" only // support the format like "bytes 11-22/*" only
s, _, _ = strings.Cut(strings.TrimPrefix(s, "bytes "), "/") s, _, _ = strings.Cut(strings.TrimPrefix(s, "bytes "), "/")
s1, s2, _ := strings.Cut(s, "-") s1, s2, _ := strings.Cut(s, "-")
start, err := strconv.ParseInt(s1, 10, 64) start, err := strconv.ParseInt(s1, 10, 64)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("parse %q: %w", s, err) return 0, fmt.Errorf("parse %q: %w", s, err)
} }
stop, err := strconv.ParseInt(s2, 10, 64) if _, err := strconv.ParseInt(s2, 10, 64); err != nil {
if err != nil { return 0, fmt.Errorf("parse %q: %w", s, err)
return 0, 0, fmt.Errorf("parse %q: %w", s, err)
} }
return start, stop, nil return start, nil
} }
File diff suppressed because it is too large. Load diff
+9 -8
View File
@@ -100,15 +100,16 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) {
return return
} }
now := time.Now().Unix() now := time.Now()
cache := &Cache{ cache := &Cache{
Repo: cred.Repo, Repo: cred.Repo,
Key: req.Key, Key: req.Key,
Version: req.Version, Version: req.Version,
Owner: owner, 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.Unix(),
UsedAt: now, CreatedAtNanos: now.Nanosecond(),
UsedAt: now.Unix(),
} }
if err := h.insertCache(db, cache); err != nil { if err := h.insertCache(db, cache); err != nil {
h.twirpError(w, r, twirpInternal, err) h.twirpError(w, r, twirpInternal, err)
+9 -36
View File
@@ -19,8 +19,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// v2Call posts a twirp request to the cache service and returns the decoded response.
// Field names are the proto ones, which is what the toolkit's client sends.
func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any { func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any {
t.Helper() t.Helper()
@@ -37,7 +35,6 @@ func v2Call(t *testing.T, handler *Handler, client *http.Client, method string,
return got return got
} }
// putBlob uploads to a signed URL and returns the status, so a test can assert a refusal.
func putBlob(t *testing.T, url string, content []byte) int { func putBlob(t *testing.T, url string, content []byte) int {
t.Helper() t.Helper()
@@ -47,8 +44,6 @@ func putBlob(t *testing.T, url string, content []byte) int {
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated { if resp.StatusCode == http.StatusCreated {
// The Azure SDK client dereferences this header without checking, so a blob upload that
// omits it panics the caller rather than failing it.
require.NotEmpty(t, resp.Header.Get("x-ms-request-id")) require.NotEmpty(t, resp.Header.Get("x-ms-request-id"))
} }
return resp.StatusCode return resp.StatusCode
@@ -67,8 +62,6 @@ func getURL(t *testing.T, url string) []byte {
return body return body
} }
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
// with the upload URL it used.
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) { func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
t.Helper() t.Helper()
@@ -84,10 +77,8 @@ func saveV2(t *testing.T, handler *Handler, key, version string, content []byte)
}), uploadURL }), uploadURL
} }
// The whole round trip an actions/cache v2 client makes, plus the guarantees on the signed
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
// or to replace a finalized entry.
func TestCacheServiceV2RoundTrip(t *testing.T) { func TestCacheServiceV2RoundTrip(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{}) handler := newTestHandler(t, Policy{})
content := []byte("the cached archive") content := []byte("the cached archive")
@@ -98,10 +89,8 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
require.Equal(t, true, finalized["ok"]) require.Equal(t, true, finalized["ok"])
assert.NotEmpty(t, finalized["entry_id"]) assert.NotEmpty(t, finalized["entry_id"])
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
// and it is an upload URL only: nothing reads a blob back through it.
assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned"))) assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned")))
resp, err := http.Get(uploadURL) //nolint:noctx // the URL is the server under test resp, err := http.Get(uploadURL)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
@@ -114,9 +103,8 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
assert.Equal(t, content, getURL(t, downloadURL)) assert.Equal(t, content, getURL(t, downloadURL))
} }
// A large archive is staged as blocks and only put in order by the final block list, so
// blocks that arrive out of order must still be assembled the way the client asked.
func TestCacheServiceV2BlockUpload(t *testing.T) { func TestCacheServiceV2BlockUpload(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{}) handler := newTestHandler(t, Policy{})
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"}) created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
@@ -130,7 +118,6 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
blocks[blockID] = []byte(part) blocks[blockID] = []byte(part)
order = append(order, blockID) order = append(order, blockID)
} }
// Upload in an order that is not the block list order.
for _, blockID := range []string{order[2], order[0], order[1]} { for _, blockID := range []string{order[2], order[0], order[1]} {
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID])) require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID]))
} }
@@ -165,6 +152,7 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
} }
func TestCacheServiceV2Lookups(t *testing.T) { func TestCacheServiceV2Lookups(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{}) handler := newTestHandler(t, Policy{})
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x")) saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
require.Equal(t, true, saved["ok"]) require.Equal(t, true, saved["ok"])
@@ -174,8 +162,6 @@ func TestCacheServiceV2Lookups(t *testing.T) {
assert.Equal(t, false, got["ok"]) assert.Equal(t, false, got["ok"])
}) })
// The toolkit serialises with the proto field names; the camelCase spellings of the same
// proto JSON mapping are accepted alongside them.
for _, field := range []string{"restore_keys", "restoreKeys"} { for _, field := range []string{"restore_keys", "restoreKeys"} {
t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) { t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) {
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{ got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{
@@ -191,9 +177,6 @@ func TestCacheServiceV2Lookups(t *testing.T) {
assert.Equal(t, false, again["ok"]) assert.Equal(t, false, again["ok"])
}) })
// A key that is only a prefix of an existing one is a different entry, so the
// reservation check must be exact and not a restore-key prefix match, or the shorter
// key would be reported as existing and silently never saved.
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) { t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"}) reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
require.Equal(t, true, reserved["ok"]) require.Equal(t, true, reserved["ok"])
@@ -219,7 +202,6 @@ func TestCacheServiceV2Lookups(t *testing.T) {
assert.Equal(t, false, got["ok"]) assert.Equal(t, false, got["ok"])
}) })
// The size the client declares is what Commit validates the assembled archive against.
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) { t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"}) created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte("four"))) require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte("four")))
@@ -230,8 +212,6 @@ func TestCacheServiceV2Lookups(t *testing.T) {
assert.Equal(t, false, got["ok"]) assert.Equal(t, false, got["ok"])
}) })
// Both API versions are served from one store, so an entry written through v2 is a hit for
// a v1 client asking for the same key and version.
t.Run("a v1 client sees an entry written through v2", func(t *testing.T) { t.Run("a v1 client sees an entry written through v2", func(t *testing.T) {
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath)) resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath))
require.NoError(t, err) require.NoError(t, err)
@@ -244,7 +224,6 @@ func TestCacheServiceV2Lookups(t *testing.T) {
assert.NotEmpty(t, got["archiveLocation"]) assert.NotEmpty(t, got["archiveLocation"])
}) })
// The cache of one repository must stay invisible to another, as it does for the v1 API.
t.Run("another repository sees nothing", func(t *testing.T) { t.Run("another repository sees nothing", func(t *testing.T) {
handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"}) handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"})
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}} otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
@@ -255,6 +234,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
} }
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) { func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{}) handler := newTestHandler(t, Policy{})
tests := []struct { tests := []struct {
@@ -291,6 +271,7 @@ func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
} }
func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) { func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, Policy{}) handler := newTestHandler(t, Policy{})
content := []byte("the cached archive") content := []byte("the cached archive")
@@ -321,20 +302,12 @@ func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
"another job's upload is not this job's to finalize") "another job's upload is not this job's to finalize")
base := handler.ExternalURL() + apiPath base := handler.ExternalURL() + apiPath
reserveV1, err := json.Marshal(&Request{Key: "v1-made", Version: "v1", Size: int64(len(content))}) madeByV1 := reserveCache(t, testClient, base, "v1-made", "v1", 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, upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, madeByV1.CacheID), bytes.NewReader(content)) fmt.Sprintf("%s/caches/%d", base, madeByV1), bytes.NewReader(content))
require.NoError(t, err) require.NoError(t, err)
upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1)) upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
resp, err = testClient.Do(upload) resp, err := testClient.Do(upload)
require.NoError(t, err) require.NoError(t, err)
resp.Body.Close() resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode)
+16 -9
View File
@@ -4,6 +4,8 @@
package artifactcache package artifactcache
import "time"
type Request struct { type Request struct {
Key string `json:"key" ` Key string `json:"key" `
Version string `json:"version"` Version string `json:"version"`
@@ -11,13 +13,18 @@ type Request struct {
} }
type Cache struct { type Cache struct {
ID uint64 `json:"id" boltholdKey:"ID"` ID uint64 `json:"id"`
Repo string `json:"repo" boltholdIndex:"Repo"` Repo string `json:"repo"`
Key string `json:"key" boltholdIndex:"Key"` Key string `json:"key"`
Version string `json:"version" boltholdIndex:"Version"` Version string `json:"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 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"`
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"` UsedAt int64 `json:"usedAt"`
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"` CreatedAt int64 `json:"createdAt"`
CreatedAtNanos int `json:"createdAtNanos,omitzero"`
}
func (cache *Cache) createdAtNano() int64 {
return cache.CreatedAt*int64(time.Second) + int64(cache.CreatedAtNanos)
} }
+2 -8
View File
@@ -4,6 +4,7 @@
package artifactcache package artifactcache
import ( import (
"cmp"
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
@@ -74,14 +75,7 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
// A protocol switch loses the bearer or the agent, and an untrusted instance needs skipped verification. // 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 { func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL) bool {
if cred.InsecureTLS && target.Scheme == "https" { return cred.InsecureTLS && target.Scheme == "https" || cmp.Or(r.Header.Get("X-Forwarded-Proto"), "http") != target.Scheme
return true
}
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
scheme = "http"
}
return scheme != target.Scheme
} }
func mergeQuery(target, request string) string { func mergeQuery(target, request string) string {
+1
View File
@@ -17,6 +17,7 @@ import (
) )
func TestFrontResultsService(t *testing.T) { func TestFrontResultsService(t *testing.T) {
t.Parallel()
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) {
gotHost, gotPath, gotProto = r.Host, r.URL.Path, r.Header.Get("X-Forwarded-Proto") gotHost, gotPath, gotProto = r.Host, r.URL.Path, r.Header.Get("X-Forwarded-Proto")
+32 -10
View File
@@ -24,6 +24,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.com/gitea/runner/act/common"
"golang.org/x/sync/errgroup"
) )
// S3Options configures the bucket that stores completed cache entries. // S3Options configures the bucket that stores completed cache entries.
@@ -47,7 +51,7 @@ type cacheStorage interface {
Remove(uint64) error Remove(uint64) error
} }
const s3PartSize = 5 << 30 // the largest part S3 accepts const s3PartSize = 64 << 20
// s3Storage stages uploads on disk and keeps completed entries and their metadata in a bucket. // s3Storage stages uploads on disk and keeps completed entries and their metadata in a bucket.
type s3Storage struct { type s3Storage struct {
@@ -69,6 +73,7 @@ func newS3Storage(staging *Storage, opts S3Options) (*s3Storage, error) {
} }
transport, _ := http.DefaultTransport.(*http.Transport) transport, _ := http.DefaultTransport.(*http.Transport)
transport = transport.Clone() transport = transport.Clone()
transport.MaxIdleConnsPerHost = 32 // 4 parts each for the 8 jobs a runner commonly runs
transport.ResponseHeaderTimeout = time.Minute // a stalled endpoint must not hold the store forever transport.ResponseHeaderTimeout = time.Minute // a stalled endpoint must not hold the store forever
pathStyle := !strings.HasSuffix(endpoint.Hostname(), ".amazonaws.com") pathStyle := !strings.HasSuffix(endpoint.Hostname(), ".amazonaws.com")
if opts.PathStyle != nil { if opts.PathStyle != nil {
@@ -209,26 +214,43 @@ func (s *s3Storage) Commit(id uint64, size int64) (int64, error) {
return written, nil return written, nil
} }
// upload always goes in parts, the only way S3 takes an object over 5 GiB. func (s *s3Storage) upload(ctx context.Context, key string, file io.ReaderAt, size int64) (uploadErr error) {
func (s *s3Storage) upload(ctx context.Context, key string, file io.ReaderAt, size int64) error { if size <= s3PartSize {
_, err := s.call(ctx, http.MethodPut, key, nil, io.NewSectionReader(file, 0, size), nil)
return err
}
var created struct { var created struct {
UploadID string `xml:"UploadId"` UploadID string `xml:"UploadId"`
} }
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploads": {""}}, nil, &created); err != nil { if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploads": {""}}, nil, &created); err != nil {
return err return err
} }
defer func() {
if uploadErr != nil && created.UploadID != "" {
abortCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
_, _ = s.call(abortCtx, http.MethodDelete, key, url.Values{"uploadId": {created.UploadID}}, nil, nil)
}
}()
var completion struct { var completion struct {
XMLName xml.Name `xml:"CompleteMultipartUpload"` XMLName xml.Name `xml:"CompleteMultipartUpload"`
Parts []s3Part `xml:"Part"` Parts []s3Part `xml:"Part"`
} }
for offset := int64(0); offset == 0 || offset < size; offset += s3PartSize { partSize := max(int64(s3PartSize), (size-1)/10000+1)
number, length := len(completion.Parts)+1, min(s3PartSize, size-offset) completion.Parts = make([]s3Part, (size-1)/partSize+1)
query := url.Values{"partNumber": {strconv.Itoa(number)}, "uploadId": {created.UploadID}} workers, workerCtx := errgroup.WithContext(ctx)
header, err := s.call(ctx, http.MethodPut, key, query, io.NewSectionReader(file, offset, length), nil) workers.SetLimit(4)
if err != nil { for part := range completion.Parts {
workers.Go(func() (err error) {
defer common.RecoverPanic(&err)
offset := int64(part) * partSize
header, err := s.call(workerCtx, http.MethodPut, key, url.Values{"partNumber": {strconv.Itoa(part + 1)}, "uploadId": {created.UploadID}}, io.NewSectionReader(file, offset, min(partSize, size-offset)), nil)
completion.Parts[part] = s3Part{part + 1, header.Get("ETag")}
return err return err
} })
completion.Parts = append(completion.Parts, s3Part{number, header.Get("ETag")}) }
if err := workers.Wait(); err != nil {
return err
} }
body, err := xml.Marshal(completion) body, err := xml.Marshal(completion)
if err != nil { if err != nil {
+102 -7
View File
@@ -4,40 +4,135 @@
package artifactcache package artifactcache
import ( import (
"encoding/xml"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing" "testing"
"time" "time"
"github.com/johannesboyne/gofakes3"
"github.com/johannesboyne/gofakes3/backend/s3mem"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestHandler_S3SharesEntriesAndDropsExpiredOnes(t *testing.T) { func TestHandler_S3SharesEntriesAndDropsExpiredOnes(t *testing.T) {
backend := s3mem.New() t.Parallel()
require.NoError(t, backend.CreateBucket("cache")) var objects sync.Map
server := httptest.NewServer(gofakes3.New(backend).Server()) var uploads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
key := strings.TrimPrefix(request.URL.Path, "/cache/")
switch {
case request.Method == http.MethodPut:
if strings.Contains(key, "/blobs/") {
uploads.Add(1)
}
body, err := io.ReadAll(request.Body)
assert.NoError(t, err)
objects.Store(key, body)
case request.URL.Query().Has("list-type"):
_, _ = io.WriteString(writer, "<ListBucketResult>")
objects.Range(func(stored, _ any) bool {
_, _ = io.WriteString(writer, "<Contents><Key>"+stored.(string)+"</Key></Contents>")
return true
})
_, _ = io.WriteString(writer, "</ListBucketResult>")
default:
if body, ok := objects.Load(key); ok {
_, _ = writer.Write(body.([]byte))
} else {
writer.WriteHeader(http.StatusNotFound)
}
}
}))
t.Cleanup(server.Close) t.Cleanup(server.Close)
opts := Options{S3: &S3Options{Endpoint: server.URL, Bucket: "cache", Prefix: "runners", AccessKey: "key", SecretKey: "secret"}} opts := Options{S3: &S3Options{Endpoint: server.URL, Bucket: "cache", Prefix: "runners", AccessKey: "key", SecretKey: "secret"}}
saver, restorer := startTestHandler(t, opts), startTestHandler(t, opts) saver, restorer := startTestHandler(t, opts), startTestHandler(t, opts)
entry := map[string]any{"key": "deps", "version": "v1"} entry := map[string]any{"key": "deps", "version": "v1"}
started := time.Now()
finalized, _ := saveV2(t, saver, "deps", "v1", []byte("archive")) finalized, _ := saveV2(t, saver, "deps", "v1", []byte("archive"))
require.Equal(t, true, finalized["ok"]) require.Equal(t, true, finalized["ok"])
assert.EqualValues(t, 1, uploads.Load())
id, err := strconv.ParseUint(finalized["entry_id"].(string), 10, 64)
require.NoError(t, err)
cache, err := saver.s3.fetch(t.Context(), id)
require.NoError(t, err)
assert.WithinRange(t, time.Unix(cache.CreatedAt, int64(cache.CreatedAtNanos)), started, time.Now())
require.NoError(t, restorer.importMetadata(t.Context())) require.NoError(t, restorer.importMetadata(t.Context()))
found := v2Call(t, restorer, testClient, "GetCacheEntryDownloadURL", entry) found := v2Call(t, restorer, testClient, "GetCacheEntryDownloadURL", entry)
require.Equal(t, true, found["ok"]) require.Equal(t, true, found["ok"])
assert.Equal(t, []byte("archive"), getURL(t, found["signed_download_url"].(string))) assert.Equal(t, []byte("archive"), getURL(t, found["signed_download_url"].(string)))
require.NoError(t, backend.ForceDeleteBucket("cache")) for _, cache := range []*Cache{
require.NoError(t, backend.CreateBucket("cache")) {ID: 2, Repo: testRepo, Key: "deps-old", Version: "v1", Complete: true, CreatedAt: cache.CreatedAt},
{ID: 1, Repo: testRepo, Key: "deps-new", Version: "v1", Complete: true, CreatedAt: cache.CreatedAt, CreatedAtNanos: 1},
} {
require.NoError(t, saver.s3.publish(cache))
objects.Store(saver.s3.blobKey(cache.ID), []byte(cache.Key))
}
require.NoError(t, restorer.importMetadata(t.Context()))
found = v2Call(t, restorer, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-", "version": "v1"})
require.Equal(t, true, found["ok"])
assert.Equal(t, "deps-new", found["matched_key"])
assert.Equal(t, []byte("deps-new"), getURL(t, found["signed_download_url"].(string)))
objects.Clear()
require.NoError(t, restorer.importMetadata(t.Context())) require.NoError(t, restorer.importMetadata(t.Context()))
assert.Equal(t, true, v2Call(t, restorer, testClient, "CreateCacheEntry", entry)["ok"]) assert.Equal(t, true, v2Call(t, restorer, testClient, "CreateCacheEntry", entry)["ok"])
} }
type s3TestTransport func(*http.Request) (*http.Response, error)
func (transport s3TestTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return transport(req)
}
func TestS3StorageUploadsLargeObjectsInPartsAndAbortsOnFailure(t *testing.T) {
t.Parallel()
for _, failingPart := range []string{"", "2"} {
var parts, aborts atomic.Int32
var completion struct {
Parts []s3Part `xml:"Part"`
}
storage := &s3Storage{bucketURL: httptest.NewRequest(http.MethodGet, "http://cache.local/bucket", nil).URL}
storage.client = &http.Client{Transport: s3TestTransport(func(req *http.Request) (*http.Response, error) {
query, response := req.URL.Query(), &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: http.NoBody}
switch {
case query.Has("uploads"):
response.Body = io.NopCloser(strings.NewReader("<InitiateMultipartUploadResult><UploadId>upload</UploadId></InitiateMultipartUploadResult>"))
case req.Method == http.MethodPut:
parts.Add(1)
response.Header.Set("ETag", "etag-"+query.Get("partNumber"))
if query.Get("partNumber") == failingPart {
response.StatusCode, response.Body = http.StatusBadRequest, io.NopCloser(strings.NewReader("<Error><Code>InvalidPart</Code></Error>"))
}
case req.Method == http.MethodPost:
require.NoError(t, xml.NewDecoder(req.Body).Decode(&completion))
response.Body = io.NopCloser(strings.NewReader("<CompleteMultipartUploadResult/>"))
case req.Method == http.MethodDelete:
aborts.Add(1)
}
return response, nil
})}
err := storage.upload(t.Context(), "blobs/1", strings.NewReader(""), 2*int64(s3PartSize)+1)
assert.EqualValues(t, 3, parts.Load())
if failingPart == "" {
require.NoError(t, err)
assert.Equal(t, []s3Part{{1, "etag-1"}, {2, "etag-2"}, {3, "etag-3"}}, completion.Parts)
assert.Zero(t, aborts.Load())
} else {
require.ErrorContains(t, err, "InvalidPart")
assert.EqualValues(t, 1, aborts.Load())
}
}
}
func TestS3StorageSignsLikeAWS(t *testing.T) { func TestS3StorageSignsLikeAWS(t *testing.T) {
t.Parallel()
storage := &s3Storage{opts: S3Options{Region: "us-east-1", AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}} storage := &s3Storage{opts: S3Options{Region: "us-east-1", AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}}
req := httptest.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", nil) req := httptest.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", nil)
req.Header.Set("X-Amz-Content-Sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") req.Header.Set("X-Amz-Content-Sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
+5 -10
View File
@@ -32,13 +32,11 @@ func NewStorage(rootDir string) (*Storage, error) {
} }
func (s *Storage) Exist(id uint64) (bool, error) { func (s *Storage) Exist(id uint64) (bool, error) {
name := s.filename(id) _, err := os.Stat(s.filename(id))
if _, err := os.Stat(name); os.IsNotExist(err) { if os.IsNotExist(err) {
return false, nil return false, nil
} else if err != nil {
return false, err
} }
return true, nil return err == nil, err
} }
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error { func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
@@ -92,9 +90,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
} }
func (s *Storage) Commit(id uint64, size int64) (int64, error) { func (s *Storage) Commit(id uint64, size int64) (int64, error) {
defer func() { defer func() { _ = os.RemoveAll(s.tempDir(id)) }()
_ = os.RemoveAll(s.tempDir(id))
}()
name := s.filename(id) name := s.filename(id)
tempNames, err := s.tempNames(id) tempNames, err := s.tempNames(id)
@@ -153,8 +149,7 @@ func assemble(name string, tempNames []string) (int64, error) {
} }
func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) { func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
name := s.filename(id) http.ServeFile(w, r, s.filename(id))
http.ServeFile(w, r, name)
} }
// Remove deletes an entry's blob and any staged parts. It reports failure so the caller can // Remove deletes an entry's blob and any staged parts. It reports failure so the caller can
+24 -17
View File
@@ -13,6 +13,7 @@ import (
"io/fs" "io/fs"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -79,22 +80,30 @@ func uploads(router *http.ServeMux, baseDir string) {
itemPath += gzipExtension itemPath += gzipExtension
} }
safeRunPath := safeResolve(baseDir, req.PathValue("runId")) safePath := safeResolve(safeResolve(baseDir, req.PathValue("runId")), itemPath)
safePath := safeResolve(safeRunPath, itemPath)
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil { if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
panic(err) panic(err)
} }
flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC var start int64
appendUpload := req.Header.Get("Content-Range") if contentRange := req.Header.Get("Content-Range"); contentRange != "" {
if appendUpload != "" && !strings.HasPrefix(appendUpload, "bytes 0-") { if _, err := fmt.Sscanf(contentRange, "bytes %d-", &start); err != nil || start < 0 {
flags = os.O_CREATE | os.O_WRONLY | os.O_APPEND http.Error(w, "invalid content range", http.StatusBadRequest)
return
}
}
flags := os.O_CREATE | os.O_WRONLY
if start == 0 {
flags |= os.O_TRUNC
} }
file, err := os.OpenFile(safePath, flags, 0o644) file, err := os.OpenFile(safePath, flags, 0o644)
if err != nil { if err != nil {
panic(err) panic(err)
} }
defer file.Close() defer file.Close()
if _, err := file.Seek(start, io.SeekStart); err != nil {
panic(err)
}
if req.Body == nil { if req.Body == nil {
panic(errors.New("no body given")) panic(errors.New("no body given"))
} }
@@ -120,10 +129,8 @@ func downloads(router *http.ServeMux, baseDir string) {
router.HandleFunc("GET /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) { router.HandleFunc("GET /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) {
runID := req.PathValue("runId") runID := req.PathValue("runId")
safePath := safeResolve(baseDir, runID) entries, err := os.ReadDir(safeResolve(baseDir, runID))
if err != nil && !os.IsNotExist(err) {
entries, err := os.ReadDir(safePath)
if err != nil {
panic(err) panic(err)
} }
@@ -148,6 +155,9 @@ func downloads(router *http.ServeMux, baseDir string) {
var files []ContainerItem var files []ContainerItem
err := filepath.WalkDir(safePath, func(path string, entry fs.DirEntry, err error) error { err := filepath.WalkDir(safePath, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() { if !entry.IsDir() {
rel, err := filepath.Rel(safePath, path) rel, err := filepath.Rel(safePath, path)
if err != nil { if err != nil {
@@ -156,20 +166,16 @@ func downloads(router *http.ServeMux, baseDir string) {
// if it was upload as gzip // if it was upload as gzip
rel = strings.TrimSuffix(rel, gzipExtension) rel = strings.TrimSuffix(rel, gzipExtension)
path := filepath.Join(itemPath, rel)
rel = filepath.ToSlash(rel)
path = filepath.ToSlash(path)
files = append(files, ContainerItem{ files = append(files, ContainerItem{
Path: path, Path: filepath.ToSlash(filepath.Join(itemPath, rel)),
ItemType: "file", ItemType: "file",
ContentLocation: fmt.Sprintf("http://%s/artifact/%s/%s/%s", req.Host, container, itemPath, rel), ContentLocation: (&url.URL{Scheme: "http", Host: req.Host, Path: "/artifact/" + container + "/" + itemPath + "/" + filepath.ToSlash(rel)}).String(),
}) })
} }
return nil return nil
}) })
if err != nil { if err != nil && !os.IsNotExist(err) {
panic(err) panic(err)
} }
@@ -212,6 +218,7 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
logger.Debugf("Artifacts base path '%s'", artifactPath) logger.Debugf("Artifacts base path '%s'", artifactPath)
uploads(router, artifactPath) uploads(router, artifactPath)
downloads(router, artifactPath) downloads(router, artifactPath)
registerV4(router, artifactPath)
server := &http.Server{ server := &http.Server{
Addr: net.JoinHostPort(addr, port), Addr: net.JoinHostPort(addr, port),
+112 -7
View File
@@ -28,6 +28,7 @@ func TestArtifactFlow(t *testing.T) {
router := http.NewServeMux() router := http.NewServeMux()
uploads(router, artifactPath) uploads(router, artifactPath)
downloads(router, artifactPath) downloads(router, artifactPath)
registerV4(router, artifactPath)
server := httptest.NewServer(router) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
@@ -48,8 +49,17 @@ func TestArtifactFlow(t *testing.T) {
return resp.StatusCode, data return resp.StatusCode, data
} }
t.Run("no-artifacts", func(t *testing.T) {
status, data := request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/0/artifacts", nil, nil)
require.Equal(t, http.StatusOK, status, string(data))
var list NamedFileContainerResourceURLResponse
require.NoError(t, json.Unmarshal(data, &list))
require.Zero(t, list.Count)
require.Empty(t, list.Value)
})
t.Run("upload-and-download", func(t *testing.T) { t.Run("upload-and-download", func(t *testing.T) {
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n" const runID, item, content = "1", "my-artifact/data#1%25.txt", "hello artifact\n"
status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil) status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
require.Equal(t, http.StatusOK, status, string(data)) require.Equal(t, http.StatusOK, status, string(data))
@@ -81,13 +91,13 @@ func TestArtifactFlow(t *testing.T) {
require.NoError(t, json.Unmarshal(data, &items)) require.NoError(t, json.Unmarshal(data, &items))
require.Len(t, items.Value, 1) require.Len(t, items.Value, 1)
require.Equal(t, "file", items.Value[0].ItemType) require.Equal(t, "file", items.Value[0].ItemType)
require.Equal(t, "my-artifact/data.txt", items.Value[0].Path) require.Equal(t, item, items.Value[0].Path)
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil) status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
require.Equal(t, http.StatusOK, status) require.Equal(t, http.StatusOK, status)
require.Equal(t, content, string(data)) require.Equal(t, content, string(data))
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt")) stored, err := os.ReadFile(filepath.Join(artifactPath, runID, filepath.FromSlash(item)))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, content, string(stored)) require.Equal(t, content, string(stored))
}) })
@@ -95,16 +105,25 @@ func TestArtifactFlow(t *testing.T) {
t.Run("content-range", func(t *testing.T) { t.Run("content-range", func(t *testing.T) {
const rawURL = "/upload/4?itemPath=chunks.txt" const rawURL = "/upload/4?itemPath=chunks.txt"
status, data := request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("first"), status, data := request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("first"),
http.Header{"Content-Range": []string{"bytes 0-4/11"}}) http.Header{"Content-Range": []string{"bytes 0-4/12"}})
require.Equal(t, http.StatusOK, status, string(data)) require.Equal(t, http.StatusOK, status, string(data))
status, data = request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("-second"), for range 2 {
http.Header{"Content-Range": []string{"bytes 5-11/11"}}) status, data = request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("-second"),
require.Equal(t, http.StatusOK, status, string(data)) http.Header{"Content-Range": []string{"bytes 5-11/12"}})
require.Equal(t, http.StatusOK, status, string(data))
}
stored, err := os.ReadFile(filepath.Join(artifactPath, "4", "chunks.txt")) stored, err := os.ReadFile(filepath.Join(artifactPath, "4", "chunks.txt"))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "first-second", string(stored)) require.Equal(t, "first-second", string(stored))
status, data = request(t, http.MethodPut, baseURL+rawURL, strings.NewReader(""),
http.Header{"Content-Range": []string{"bytes 0--1/0"}})
require.Equal(t, http.StatusOK, status, string(data))
stored, err = os.ReadFile(filepath.Join(artifactPath, "4", "chunks.txt"))
require.NoError(t, err)
require.Empty(t, stored)
}) })
t.Run("gzip-roundtrip", func(t *testing.T) { t.Run("gzip-roundtrip", func(t *testing.T) {
@@ -156,6 +175,92 @@ func TestArtifactFlow(t *testing.T) {
require.Equal(t, http.StatusOK, status) require.Equal(t, http.StatusOK, status)
require.Equal(t, content, string(data)) require.Equal(t, content, string(data))
}) })
t.Run("v4", func(t *testing.T) {
const artifactRef = `"workflow_run_backend_id":"7","name":"50% art@v1"`
call := func(method, body string) (int, map[string]any) {
status, data := request(t, http.MethodPost, baseURL+artifactServicePath+"/"+method, strings.NewReader(body), nil)
var out map[string]any
require.NoError(t, json.Unmarshal(data, &out), string(data))
return status, out
}
put := func(rawURL, body string) int {
status, _ := request(t, http.MethodPut, rawURL, strings.NewReader(body), nil)
return status
}
download := func() (string, http.Header) {
status, signed := call("GetSignedArtifactURL", "{"+artifactRef+"}")
require.Equal(t, http.StatusOK, status, signed)
resp, err := client.Get(signed["signedUrl"].(string))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return string(body), resp.Header
}
status, created := call("CreateArtifact", "{"+artifactRef+"}")
require.Equal(t, http.StatusOK, status, created)
upload := created["signedUploadUrl"].(string)
require.Equal(t, http.StatusCreated, put(upload+"&comp=block&blockid=Yg", "world"))
require.Equal(t, http.StatusCreated, put(upload+"&comp=block&blockid=YQ", "hello "))
for attempt := range 2 {
require.Equal(t, http.StatusCreated, put(upload+"&comp=blocklist", `<?xml version="1.0"?><BlockList><Latest>YQ</Latest><Latest>Yg</Latest></BlockList>`), "commit attempt %d", attempt)
}
digest := "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
status, _ = call("FinalizeArtifact", "{"+artifactRef+`,"size":"10"}`)
require.Equal(t, http.StatusBadRequest, status)
status, finalized := call("FinalizeArtifact", "{"+artifactRef+`,"size":"11","hash":"`+digest+`"}`)
require.Equal(t, http.StatusOK, status, finalized)
id := finalized["artifactId"].(string)
status, refinalized := call("FinalizeArtifact", "{"+artifactRef+`,"size":"10"}`)
require.Equal(t, http.StatusOK, status)
require.Equal(t, id, refinalized["artifactId"])
status, conflict := call("CreateArtifact", "{"+artifactRef+"}")
require.Equal(t, http.StatusConflict, status)
require.Equal(t, "already_exists", conflict["code"])
status, listed := call("ListArtifacts", `{"workflowRunBackendId":"7","idFilter":"`+id+`"}`)
require.Equal(t, http.StatusOK, status)
artifacts := listed["artifacts"].([]any)
require.Len(t, artifacts, 1)
artifact := artifacts[0].(map[string]any)
require.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, artifact["createdAt"])
delete(artifact, "createdAt")
require.Equal(t, map[string]any{"workflowRunBackendId": "7", "name": "50% art@v1", "size": "11", "digest": digest, "databaseId": id}, artifact)
_, other := call("ListArtifacts", `{"workflow_run_backend_id":"8"}`)
require.Empty(t, other["artifacts"])
body, header := download()
require.Equal(t, "hello world", body)
require.Equal(t, "application/zip", header.Get("Content-Type"))
require.Equal(t, "attachment; filename*=UTF-8''50%25%20art%40v1.zip", header.Get("Content-Disposition"))
status, _ = call("DeleteArtifact", "{"+artifactRef+"}")
require.Equal(t, http.StatusOK, status)
status, missing := call("GetSignedArtifactURL", "{"+artifactRef+"}")
require.Equal(t, http.StatusNotFound, status)
require.Equal(t, "not_found", missing["code"])
status, created = call("CreateArtifact", "{"+artifactRef+"}")
require.Equal(t, http.StatusOK, status, created)
status, _ = request(t, http.MethodPut, created["signedUploadUrl"].(string), strings.NewReader("gz"), http.Header{"X-Ms-Blob-Content-Type": {"application/gzip"}})
require.Equal(t, http.StatusCreated, status)
status, _ = call("FinalizeArtifact", "{"+artifactRef+`,"size":"2"}`)
require.Equal(t, http.StatusOK, status)
body, header = download()
require.Equal(t, "gz", body)
require.Equal(t, "application/gzip", header.Get("Content-Type"))
require.Equal(t, "attachment; filename*=UTF-8''50%25%20art%40v1", header.Get("Content-Disposition"))
rerun := http.NewServeMux()
registerV4(rerun, artifactPath)
recorder := httptest.NewRecorder()
rerun.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, artifactServicePath+"/CreateArtifact", strings.NewReader("{"+artifactRef+"}")))
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
})
} }
func TestSafeResolve(t *testing.T) { func TestSafeResolve(t *testing.T) {
+329
View File
@@ -0,0 +1,329 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifacts
import (
"cmp"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"encoding/xml"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
type serverV4 struct { // the subset of Gitea's v4 artifact service that the toolkit uses, unauthenticated like the v3 routes beside it
dir string
mu sync.Mutex
lastID int64
created map[string]bool
}
const (
zipMimeType = "application/zip"
maxTwirpBytes = 1 << 20
)
func registerV4(router *http.ServeMux, baseDir string) {
server := &serverV4{dir: filepath.Join(baseDir, "v4"), lastID: time.Now().UnixMilli(), created: map[string]bool{}} // IDs stay unique across invocations sharing baseDir
router.HandleFunc("POST "+artifactServicePath+"/CreateArtifact", twirp(server.createArtifact))
router.HandleFunc("POST "+artifactServicePath+"/FinalizeArtifact", twirp(server.finalizeArtifact))
router.HandleFunc("POST "+artifactServicePath+"/ListArtifacts", twirp(server.listArtifacts))
router.HandleFunc("POST "+artifactServicePath+"/GetSignedArtifactURL", twirp(server.getSignedArtifactURL))
router.HandleFunc("POST "+artifactServicePath+"/DeleteArtifact", twirp(server.deleteArtifact))
router.HandleFunc("PUT "+artifactServicePath+"/UploadArtifact", server.uploadArtifact)
router.HandleFunc("GET "+artifactServicePath+"/DownloadArtifact", server.downloadArtifact)
}
type twirpError struct {
status int
code string
msg string
}
func (e *twirpError) Error() string { return e.msg }
func notFound(name string) error {
return &twirpError{http.StatusNotFound, "not_found", fmt.Sprintf("artifact %q not found", name)}
}
func badRequest(format string, args ...any) error {
return &twirpError{http.StatusBadRequest, "invalid_argument", fmt.Sprintf(format, args...)}
}
func twirp[Req, Resp any](handle func(*http.Request, Req) (Resp, error)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req Req
var resp Resp
err := json.UnmarshalRead(io.LimitReader(r.Body, maxTwirpBytes), &req, json.MatchCaseInsensitiveNames(true)) // also matches the toolkit's proto field names
if err != nil {
err = badRequest("malformed request: %v", err)
} else {
resp, err = handle(r, req)
}
w.Header().Set("Content-Type", "application/json")
if err != nil {
terr := &twirpError{http.StatusInternalServerError, "internal", err.Error()}
errors.As(err, &terr)
w.WriteHeader(terr.status)
_ = json.MarshalWrite(w, map[string]string{"code": terr.code, "msg": terr.msg})
return
}
_ = json.MarshalWrite(w, resp)
}
}
func fileName(name string) string { // safe on any filesystem for any client-chosen name, case-insensitive ones included
sum := sha256.Sum256([]byte(name))
return hex.EncodeToString(sum[:])
}
func (s *serverV4) runDir(runID string) string {
return filepath.Join(s.dir, fileName(runID))
}
func (s *serverV4) artifactDir(runID, name string) string {
return filepath.Join(s.runDir(runID), fileName(name))
}
func (s *serverV4) urlDir(r *http.Request) string {
return s.artifactDir(r.URL.Query().Get("run"), r.URL.Query().Get("name"))
}
func readMeta(dir string) (*monolithArtifact, error) {
data, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
return nil, err
}
var meta monolithArtifact
return &meta, json.Unmarshal(data, &meta)
}
func writeMeta(dir string, meta *monolithArtifact) error {
data, err := json.Marshal(meta)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "meta.json"), data, 0o644)
}
func blobURL(r *http.Request, endpoint, runID, name string) string {
return "http://" + r.Host + artifactServicePath + "/" + endpoint + "?" + url.Values{"run": {runID}, "name": {name}}.Encode()
}
func (s *serverV4) createArtifact(r *http.Request, req createArtifactRequest) (createArtifactResponse, error) { // an exec invocation re-runs its workflows, so it replaces earlier invocations' artifacts but conflicts with its own
dir := s.artifactDir(req.WorkflowRunBackendID, req.Name)
s.mu.Lock()
defer s.mu.Unlock()
if s.created[dir] {
return createArtifactResponse{}, &twirpError{http.StatusConflict, "already_exists", "an artifact with this name already exists on the workflow run"}
}
if err := os.RemoveAll(dir); err != nil {
return createArtifactResponse{}, err
}
if err := os.MkdirAll(filepath.Join(dir, "blocks"), 0o755); err != nil {
return createArtifactResponse{}, err
}
s.lastID++
if err := writeMeta(dir, &monolithArtifact{
WorkflowRunBackendID: req.WorkflowRunBackendID,
DatabaseID: s.lastID,
Name: req.Name,
CreatedAt: time.Now().UTC().Truncate(time.Second),
}); err != nil {
return createArtifactResponse{}, err
}
s.created[dir] = true
return createArtifactResponse{Ok: true, SignedUploadURL: blobURL(r, "UploadArtifact", req.WorkflowRunBackendID, req.Name)}, nil
}
type blockList struct {
Blocks []string `xml:",any"` // Latest, Committed and Uncommitted all name a staged block
}
func (s *serverV4) uploadArtifact(w http.ResponseWriter, r *http.Request) {
dir := s.urlDir(r)
if meta, err := readMeta(dir); err != nil || meta.Digest != "" {
http.Error(w, "no artifact awaits an upload", http.StatusNotFound)
return
}
contentType := r.Header.Get("x-ms-blob-content-type")
var err error
switch comp := r.URL.Query().Get("comp"); comp {
case "block":
err = writeFile(filepath.Join(dir, "blocks", fileName(r.URL.Query().Get("blockid"))), r.Body)
case "blocklist":
err = commitBlob(dir, contentType, func(out io.Writer) error { return joinBlocks(out, dir, r.Body) })
case "": // Put Blob, which docker/build-push-action uses
err = commitBlob(dir, contentType, func(out io.Writer) error {
_, err := io.Copy(out, r.Body)
return err
})
default:
http.Error(w, "unsupported comp "+comp, http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func writeFile(name string, body io.Reader) error {
file, err := os.Create(name)
if err != nil {
return err
}
_, err = io.Copy(file, body)
return errors.Join(err, file.Close())
}
func commitBlob(dir, contentType string, write func(io.Writer) error) error { // replaces the blob whole and keeps its content type, as Azure does
out, err := os.CreateTemp(dir, "blob")
if err != nil {
return err
}
if err = errors.Join(write(out), out.Close()); err == nil {
err = os.Rename(out.Name(), filepath.Join(dir, "blob"))
}
if err != nil {
return errors.Join(err, os.Remove(out.Name()))
}
return os.WriteFile(filepath.Join(dir, "content-type"), []byte(contentType), 0o644)
}
func joinBlocks(out io.Writer, dir string, body io.Reader) error { // keeps the blocks for finalizeArtifact, so that a retried commit can join them again
var list blockList
if err := xml.NewDecoder(io.LimitReader(body, maxTwirpBytes)).Decode(&list); err != nil {
return err
}
for _, id := range list.Blocks {
if err := appendBlock(out, filepath.Join(dir, "blocks", fileName(id))); err != nil {
return err
}
}
return nil
}
func appendBlock(out io.Writer, name string) error {
in, err := os.Open(name)
if err != nil {
return err
}
defer in.Close()
_, err = io.Copy(out, in)
return err
}
func (s *serverV4) finalizeArtifact(_ *http.Request, req finalizeArtifactRequest) (finalizeArtifactResponse, error) {
dir := s.artifactDir(req.WorkflowRunBackendID, req.Name)
meta, err := readMeta(dir)
if err != nil {
return finalizeArtifactResponse{}, notFound(req.Name)
} else if meta.Digest != "" {
return finalizeArtifactResponse{Ok: true, ArtifactID: meta.DatabaseID}, nil
}
blob, err := os.Open(filepath.Join(dir, "blob"))
if err != nil {
return finalizeArtifactResponse{}, badRequest("artifact %q has no upload", req.Name)
}
defer blob.Close()
hash := sha256.New()
size, err := io.Copy(hash, blob)
if err != nil {
return finalizeArtifactResponse{}, err
}
digest := "sha256:" + hex.EncodeToString(hash.Sum(nil))
if size != req.Size {
return finalizeArtifactResponse{}, badRequest("size %d does not match the %d bytes uploaded", req.Size, size)
}
if req.Hash != "" && req.Hash != digest {
return finalizeArtifactResponse{}, badRequest("hash %s does not match the uploaded %s", req.Hash, digest)
}
s.mu.Lock() // only after hashing, so finalizing one large artifact does not stall the others
defer s.mu.Unlock()
if current, err := readMeta(dir); err != nil || current.DatabaseID != meta.DatabaseID {
return finalizeArtifactResponse{}, notFound(req.Name)
}
meta.Size, meta.Digest = size, digest
if err := writeMeta(dir, meta); err != nil {
return finalizeArtifactResponse{}, err
}
return finalizeArtifactResponse{Ok: true, ArtifactID: meta.DatabaseID}, os.RemoveAll(filepath.Join(dir, "blocks"))
}
func (s *serverV4) listArtifacts(_ *http.Request, req listArtifactsRequest) (listArtifactsResponse, error) {
run := s.runDir(req.WorkflowRunBackendID)
entries, err := os.ReadDir(run)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return listArtifactsResponse{}, err
}
var resp listArtifactsResponse
for _, entry := range entries {
meta, err := readMeta(filepath.Join(run, entry.Name()))
if err != nil || meta.Digest == "" ||
req.NameFilter != "" && req.NameFilter != meta.Name || req.IDFilter != 0 && req.IDFilter != meta.DatabaseID {
continue
}
resp.Artifacts = append(resp.Artifacts, *meta)
}
slices.SortFunc(resp.Artifacts, func(left, right monolithArtifact) int { return cmp.Compare(left.DatabaseID, right.DatabaseID) })
return resp, nil
}
func (s *serverV4) getSignedArtifactURL(r *http.Request, req getSignedArtifactURLRequest) (getSignedArtifactURLResponse, error) {
if meta, err := readMeta(s.artifactDir(req.WorkflowRunBackendID, req.Name)); err != nil || meta.Digest == "" {
return getSignedArtifactURLResponse{}, notFound(req.Name)
}
return getSignedArtifactURLResponse{SignedURL: blobURL(r, "DownloadArtifact", req.WorkflowRunBackendID, req.Name)}, nil
}
func (s *serverV4) downloadArtifact(w http.ResponseWriter, r *http.Request) {
dir := s.urlDir(r)
meta, err := readMeta(dir)
if err != nil || meta.Digest == "" {
http.Error(w, "artifact not found", http.StatusNotFound)
return
}
blob, err := os.Open(filepath.Join(dir, "blob"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer blob.Close()
data, _ := os.ReadFile(filepath.Join(dir, "content-type"))
contentType, filename := string(data), meta.Name
if !strings.Contains(contentType, "/") {
contentType = zipMimeType
}
if contentType == zipMimeType && !strings.HasSuffix(filename, ".zip") {
filename += ".zip"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+strings.ReplaceAll(url.QueryEscape(filename), "+", "%20")) // RFC 5987 attr-chars only, which the toolkit and mime.ParseMediaType both accept
http.ServeContent(w, r, "", meta.CreatedAt, blob)
}
func (s *serverV4) deleteArtifact(_ *http.Request, req deleteArtifactRequest) (deleteArtifactResponse, error) {
dir := s.artifactDir(req.WorkflowRunBackendID, req.Name)
s.mu.Lock()
defer s.mu.Unlock()
meta, err := readMeta(dir)
if err != nil {
return deleteArtifactResponse{}, notFound(req.Name)
}
delete(s.created, dir)
return deleteArtifactResponse{Ok: true, ArtifactID: meta.DatabaseID}, os.RemoveAll(dir)
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifacts
import (
"crypto/rand"
"encoding/base64"
"encoding/json/v2"
"time"
)
func LocalRuntimeToken(run string, lifetime time.Duration) string { // an unsecured JWT with the claims the toolkit and buildx read, its run hashed for scp and its jti an unguessable bearer
now := time.Now()
claims, _ := json.Marshal(map[string]any{
"scp": "Actions.Results:" + fileName(run) + ":1",
"ac": `[{"Scope":"","Permission":3}]`,
"nbf": now.Unix(),
"exp": now.Add(lifetime).Unix(),
"jti": rand.Text(),
})
return base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + "." + base64.RawURLEncoding.EncodeToString(claims) + "."
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifacts
import "time"
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService"
type ( // the ArtifactService messages in the protobuf JSON mapping, which writes int64 as strings
createArtifactResponse struct {
Ok bool `json:"ok"`
SignedUploadURL string `json:"signedUploadUrl"`
}
finalizeArtifactRequest struct {
WorkflowRunBackendID string
Name string
Size int64 `json:",string"`
Hash string
}
finalizeArtifactResponse struct {
Ok bool `json:"ok"`
ArtifactID int64 `json:"artifactId,string"`
}
listArtifactsRequest struct {
WorkflowRunBackendID string
NameFilter string
IDFilter int64 `json:",string"`
}
listArtifactsResponse struct {
Artifacts []monolithArtifact `json:"artifacts"`
}
monolithArtifact struct {
WorkflowRunBackendID string `json:"workflowRunBackendId"` // the toolkit sends it back to GetSignedArtifactURL
DatabaseID int64 `json:"databaseId,string"`
Name string `json:"name"`
Size int64 `json:"size,string"`
CreatedAt time.Time `json:"createdAt"` // whole seconds, the toolkit rejects the trimmed fractions json writes
Digest string `json:"digest"` // empty until the upload is finalized
}
getSignedArtifactURLRequest struct {
WorkflowRunBackendID string
Name string
}
getSignedArtifactURLResponse struct {
SignedURL string `json:"signedUrl"`
}
createArtifactRequest = getSignedArtifactURLRequest
deleteArtifactRequest = getSignedArtifactURLRequest
deleteArtifactResponse = finalizeArtifactResponse
)
+2 -7
View File
@@ -14,13 +14,8 @@ const dryrunContextKeyVal = dryrunContextKey("dryrun")
// Dryrun returns true if the current context is dryrun // Dryrun returns true if the current context is dryrun
func Dryrun(ctx context.Context) bool { func Dryrun(ctx context.Context) bool {
val := ctx.Value(dryrunContextKeyVal) dryrun, _ := ctx.Value(dryrunContextKeyVal).(bool)
if val != nil { return dryrun
if dryrun, ok := val.(bool); ok {
return dryrun
}
}
return false
} }
// WithDryrun adds a value to the context for dryrun // WithDryrun adds a value to the context for dryrun
+19 -23
View File
@@ -21,8 +21,7 @@ type Conditional func(ctx context.Context) bool
// NewInfoExecutor is an executor that logs messages // NewInfoExecutor is an executor that logs messages
func NewInfoExecutor(format string, args ...any) Executor { func NewInfoExecutor(format string, args ...any) Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := Logger(ctx) Logger(ctx).Infof(format, args...)
logger.Infof(format, args...)
return nil return nil
} }
} }
@@ -30,8 +29,7 @@ func NewInfoExecutor(format string, args ...any) Executor {
// NewDebugExecutor is an executor that logs messages // NewDebugExecutor is an executor that logs messages
func NewDebugExecutor(format string, args ...any) Executor { func NewDebugExecutor(format string, args ...any) Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := Logger(ctx) Logger(ctx).Debugf(format, args...)
logger.Debugf(format, args...)
return nil return nil
} }
} }
@@ -39,9 +37,7 @@ func NewDebugExecutor(format string, args ...any) Executor {
// NewPipelineExecutor creates a new executor from a series of other executors // NewPipelineExecutor creates a new executor from a series of other executors
func NewPipelineExecutor(executors ...Executor) Executor { func NewPipelineExecutor(executors ...Executor) Executor {
if len(executors) == 0 { if len(executors) == 0 {
return func(ctx context.Context) error { return NewErrorExecutor(nil)
return nil
}
} }
var rtn Executor var rtn Executor
for _, executor := range executors { for _, executor := range executors {
@@ -61,6 +57,13 @@ func NewErrorExecutor(err error) Executor {
} }
} }
func RecoverPanic(err *error) { // defer directly in workers to keep stacks in host logs
if recovered := recover(); recovered != nil {
logging.Errorf("panic: %v\n%s", recovered, debug.Stack())
*err = fmt.Errorf("panic: %v", recovered)
}
}
// NewParallelExecutor creates a new executor from a parallel of other executors // NewParallelExecutor creates a new executor from a parallel of other executors
func NewParallelExecutor(parallel int, executors ...Executor) Executor { func NewParallelExecutor(parallel int, executors ...Executor) Executor {
if len(executors) == 0 { if len(executors) == 0 {
@@ -80,8 +83,8 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
logging.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors)) logging.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors))
for i := 0; i < parallel; i++ { for workerID := range parallel {
go func(workerID int, work <-chan Executor, errs chan<- error) { go func() {
logging.Debugf("Worker %d started", workerID) logging.Debugf("Worker %d started", workerID)
taskCount := 0 taskCount := 0
for executor := range work { for executor := range work {
@@ -91,17 +94,12 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
// goroutine which would leave the runner process hung. // goroutine which would leave the runner process hung.
// https://gitea.com/gitea/runner/issues/371 // https://gitea.com/gitea/runner/issues/371
errs <- func() (err error) { errs <- func() (err error) {
defer func() { defer RecoverPanic(&err)
if r := recover(); r != nil {
logging.Errorf("panic in executor: %v\n%s", r, debug.Stack())
err = fmt.Errorf("panic: %v", r)
}
}()
return executor(ctx) return executor(ctx)
}() }()
} }
logging.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount) logging.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount)
}(i, work, errs) }()
} }
for i := range executors { for i := range executors {
@@ -150,12 +148,7 @@ func (e Executor) If(conditional Conditional) Executor {
// IfNot only runs this executor if conditional is true // IfNot only runs this executor if conditional is true
func (e Executor) IfNot(conditional Conditional) Executor { func (e Executor) IfNot(conditional Conditional) Executor {
return func(ctx context.Context) error { return e.If(func(ctx context.Context) bool { return !conditional(ctx) })
if !conditional(ctx) {
return e(ctx)
}
return nil
}
} }
// IfBool only runs this executor if conditional is true // IfBool only runs this executor if conditional is true
@@ -171,7 +164,10 @@ func (e Executor) Finally(finally Executor) Executor {
err := e(ctx) err := e(ctx)
err2 := finally(ctx) err2 := finally(ctx)
if err2 != nil { if err2 != nil {
return fmt.Errorf("error occurred running finally: %v (original error: %v)", err2, err) if err != nil {
return fmt.Errorf("error occurred running finally: %w (original error: %w)", err2, err)
}
return fmt.Errorf("error occurred running finally: %w (original error: %v)", err2, err)
} }
return err return err
} }
+61 -102
View File
@@ -7,8 +7,7 @@ package common
import ( import (
"context" "context"
"errors" "errors"
"reflect" "fmt"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -18,42 +17,25 @@ import (
) )
func TestNewWorkflow(t *testing.T) { func TestNewWorkflow(t *testing.T) {
assert := assert.New(t)
ctx := t.Context() ctx := t.Context()
require.NoError(t, NewPipelineExecutor()(ctx))
require.EqualError(t, NewErrorExecutor(errors.New("test error"))(ctx), "test error")
// empty
emptyWorkflow := NewPipelineExecutor()
assert.NoError(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
// error case
errorWorkflow := NewErrorExecutor(errors.New("test error"))
assert.Error(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
// multiple success case
runcount := 0 runcount := 0
successWorkflow := NewPipelineExecutor( increment := func(_ctx context.Context) error {
func(ctx context.Context) error { runcount++
runcount++ return nil
return nil }
}, require.NoError(t, NewPipelineExecutor(increment, increment)(ctx))
func(ctx context.Context) error { assert.Equal(t, 2, runcount)
runcount++
return nil
})
assert.NoError(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(2, runcount)
} }
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
// block until wantActive are in flight so the peak is exact without sleeping, and later copies
// find the gate already open so the last one still finishes with no partner left.
func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic.Int32) { func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic.Int32) {
var counted, active, peak atomic.Int32 var counted, active, peak atomic.Int32
var once sync.Once var once sync.Once
reached := make(chan struct{}) reached := make(chan struct{})
return func(ctx context.Context) error { return func(_ctx context.Context) error {
counted.Add(1) counted.Add(1)
running := active.Add(1) running := active.Add(1)
for { for {
@@ -72,18 +54,14 @@ func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic
} }
func TestNewParallelExecutor(t *testing.T) { func TestNewParallelExecutor(t *testing.T) {
ctx := t.Context() for name, parallel := range map[string]int{"parallel": 2, "below minimum": 0} {
t.Run(name, func(t *testing.T) {
exec, count, maxActive := concurrencyProbe(2) exec, count, maxActive := concurrencyProbe(int32(max(1, parallel)))
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx)) require.NoError(t, NewParallelExecutor(parallel, exec, exec, exec)(t.Context()))
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors") assert.Equal(t, int32(3), count.Load())
assert.Equal(t, int32(2), maxActive.Load(), "should run at most 2 executors in parallel") assert.Equal(t, int32(max(1, parallel)), maxActive.Load())
})
// parallelism below 1 falls back to a single worker }
exec, count, maxActive = concurrencyProbe(1)
require.NoError(t, NewParallelExecutor(0, exec, exec, exec)(ctx))
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
assert.Equal(t, int32(1), maxActive.Load(), "should run at most 1 executor in parallel")
} }
func TestNewParallelExecutorEmpty(t *testing.T) { func TestNewParallelExecutorEmpty(t *testing.T) {
@@ -99,66 +77,49 @@ func TestNewParallelExecutorEmpty(t *testing.T) {
assert.ErrorIs(err, context.Canceled) assert.ErrorIs(err, context.Canceled)
} }
func TestNewParallelExecutorFailed(t *testing.T) { func TestNewParallelExecutorRunsAllTasksAfterFailureOrCancellation(t *testing.T) {
assert := assert.New(t) for name, tc := range map[string]struct {
canceled bool
ctx, cancel := context.WithCancel(t.Context()) parallel, tasks int
cancel() }{
"failure": {false, 2, 5},
count := 0 "canceled": {true, 2, 5},
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error { "canceled single task": {true, 1, 1},
count++ } {
return errors.New("fake error") t.Run(name, func(t *testing.T) {
}) ctx, cancel := context.WithCancel(t.Context())
err := NewParallelExecutor(1, errorWorkflow)(ctx) defer cancel()
assert.Equal(1, count) if tc.canceled {
assert.ErrorIs(context.Canceled, err) cancel()
} }
var calls atomic.Int32
func TestNewParallelExecutorCanceled(t *testing.T) { executors := make([]Executor, tc.tasks)
assert := assert.New(t) for i := range executors {
executors[i] = func(_ctx context.Context) error {
ctx, cancel := context.WithCancel(t.Context()) calls.Add(1)
cancel() if i == tc.tasks/2 {
return errors.New("fake error")
errExpected := errors.New("fake error") }
return nil
var count atomic.Int32 }
successWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
count.Add(1)
return nil
})
errorWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
count.Add(1)
return errExpected
})
err := NewParallelExecutor(3, errorWorkflow, successWorkflow, successWorkflow)(ctx)
assert.Equal(int32(3), count.Load())
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
}
func TestNewParallelExecutorRunsRemainingAfterFailure(t *testing.T) {
var successCount atomic.Int32
executors := make([]Executor, 5)
for i := range executors {
executors[i] = func(ctx context.Context) error {
if i == 2 {
return errors.New("fake error")
} }
successCount.Add(1)
return nil
}
}
require.Error(t, NewParallelExecutor(2, executors...)(t.Context())) err := NewParallelExecutor(tc.parallel, executors...)(ctx)
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others") if tc.canceled {
require.ErrorIs(t, err, context.Canceled)
} else {
require.EqualError(t, err, "fake error")
}
assert.Equal(t, int32(tc.tasks), calls.Load())
})
}
} }
func TestExecutorConditionalsAndFinally(t *testing.T) { func TestExecutorConditionalsAndFinally(t *testing.T) {
ctx := t.Context() ctx := t.Context()
var calls []string var calls []string
record := func(name string) Executor { record := func(name string) Executor {
return func(ctx context.Context) error { return func(_ctx context.Context) error {
calls = append(calls, name) calls = append(calls, name)
return nil return nil
} }
@@ -170,19 +131,17 @@ func TestExecutorConditionalsAndFinally(t *testing.T) {
require.NoError(t, record("if-bool").IfBool(true)(ctx)) require.NoError(t, record("if-bool").IfBool(true)(ctx))
require.NoError(t, record("main").Finally(record("finally"))(ctx)) require.NoError(t, record("main").Finally(record("finally"))(ctx))
want := []string{"if-true", "if-not", "if-bool", "main", "finally"} assert.Equal(t, []string{"if-true", "if-not", "if-bool", "main", "finally"}, calls)
if !reflect.DeepEqual(calls, want) {
t.Fatalf("calls = %v, want %v", calls, want)
}
} }
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) { func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
mainErr := errors.New("main failed")
finalErr := errors.New("cleanup failed") finalErr := errors.New("cleanup failed")
for _, mainErr := range []error{context.Canceled, nil} {
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(t.Context()) err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(t.Context())
require.Error(t, err) require.EqualError(t, err, fmt.Sprintf("error occurred running finally: cleanup failed (original error: %v)", mainErr))
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") { require.ErrorIs(t, err, finalErr)
t.Fatalf("finally error = %q, want both cleanup and original error", err) if mainErr != nil {
require.ErrorIs(t, err, mainErr)
}
} }
} }
+125 -14
View File
@@ -8,6 +8,7 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"encoding/json"
"errors" "errors"
"io" "io"
"math/rand/v2" "math/rand/v2"
@@ -16,14 +17,32 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
) )
var actionFetches sync.Map
const treeSizes = ".sizes" // beside the tree rather than in it, which is copied into job containers as it is
type treeFile struct {
Name []byte // not a string, which JSON would turn into UTF-8, as git allows any bytes but NUL
Size int64
}
type actionFetch struct {
done chan struct{}
dir, sha string
err error
retry bool // the leader was cancelled or panicked, so followers fetch themselves
}
var archiveRetryDelay = func(retryAfter string) time.Duration { // as actions/runner backs off var archiveRetryDelay = func(retryAfter string) time.Duration { // as actions/runner backs off
if seconds, err := strconv.Atoi(retryAfter); err == nil { if seconds, err := strconv.Atoi(retryAfter); err == nil {
return min(max(time.Duration(seconds)*time.Second, 10*time.Second), 10*time.Minute) return min(max(time.Duration(seconds)*time.Second, 10*time.Second), 10*time.Minute)
@@ -32,6 +51,36 @@ var archiveRetryDelay = func(retryAfter string) time.Duration { // as actions/ru
} }
func FetchAction(ctx context.Context, input NewGitCloneExecutorInput) (string, string, error) { // input.Dir is one clone URL and ref's, holding a directory per commit func FetchAction(ctx context.Context, input NewGitCloneExecutorInput) (string, string, error) { // input.Dir is one clone URL and ref's, holding a directory per commit
if !reflect.ValueOf(input).Comparable() {
return fetchAction(ctx, input)
}
for ctx.Err() == nil {
call := &actionFetch{done: make(chan struct{}), retry: true}
previous, loaded := actionFetches.LoadOrStore(input, call)
if !loaded {
func() {
defer func() { actionFetches.Delete(input); close(call.done) }()
call.dir, call.sha, call.err = fetchAction(ctx, input)
call.retry = ctx.Err() != nil
}()
if call.retry {
continue
}
return call.dir, call.sha, call.err
}
call, _ = previous.(*actionFetch)
select {
case <-ctx.Done():
case <-call.done:
if !call.retry && ctx.Err() == nil {
return call.dir, call.sha, call.err
}
}
}
return "", "", ctx.Err()
}
func fetchAction(ctx context.Context, input NewGitCloneExecutorInput) (string, string, error) {
unlock, err := AcquireCloneLock(ctx, input.Dir) unlock, err := AcquireCloneLock(ctx, input.Dir)
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -45,9 +94,14 @@ func FetchAction(ctx context.Context, input NewGitCloneExecutorInput) (string, s
record := filepath.Join(input.Dir, "ref") record := filepath.Join(input.Dir, "ref")
data, _ := os.ReadFile(record) data, _ := os.ReadFile(record)
sha, etag, _ := strings.Cut(string(data), "\n") sha, etag, _ := strings.Cut(string(data), "\n")
if !isFullObjectID(sha) || !exists(tree(sha)) { switch {
case !isFullObjectID(sha):
sha, etag = "", "" sha, etag = "", ""
} else if input.OfflineMode || isFullObjectID(input.Ref) { case input.OfflineMode && exists(tree(sha)) && !exists(tree(sha)+treeSizes):
return tree(sha), sha, nil // from an older runner, with no sizes to check, so offline mode uses it as before
case !treeIntact(input.Dir, sha):
sha, etag = "", ""
case input.OfflineMode || isFullObjectID(input.Ref):
return tree(sha), sha, nil return tree(sha), sha, nil
} }
@@ -132,7 +186,7 @@ func downloadArchiveOnce(ctx context.Context, client *http.Client, archive, toke
newEtag := response.Header.Get("ETag") newEtag := response.Header.Get("ETag")
gz, err := gzip.NewReader(response.Body) gz, err := gzip.NewReader(response.Body)
if err == nil { if err == nil {
sha, err = extractTree(gz, dir, !strings.HasSuffix(newEtag, `-false"`)) // Gitea's mark of an unprefixed archive sha, err = extractTree(ctx, gz, dir, !strings.HasSuffix(newEtag, `-false"`)) // Gitea's mark of an unprefixed archive
} }
var netErr net.Error var netErr net.Error
if errors.Is(err, io.ErrUnexpectedEOF) || errors.As(err, &netErr) { if errors.Is(err, io.ErrUnexpectedEOF) || errors.As(err, &netErr) {
@@ -150,18 +204,18 @@ func exportClone(ctx context.Context, input NewGitCloneExecutorInput) (string, e
clone := input clone := input
clone.Dir = filepath.Join(input.Dir, "git") clone.Dir = filepath.Join(input.Dir, "git")
sha, _, err := resolveClone(ctx, clone) sha, _, err := resolveClone(ctx, clone)
if err != nil || exists(filepath.Join(input.Dir, sha)) { if err != nil || treeIntact(input.Dir, sha) {
return sha, err return sha, err
} }
archive, err := runGit(ctx, clone.Dir, nil, "archive", "--format=tar", sha) archive, err := runGit(ctx, clone.Dir, nil, "archive", "--format=tar", sha)
if err != nil { if err != nil {
return "", err return "", err
} }
_, err = extractTree(bytes.NewReader(archive), input.Dir, false) _, err = extractTree(ctx, bytes.NewReader(archive), input.Dir, false)
return sha, err return sha, err
} }
func extractTree(archive io.Reader, dir string, strip bool) (string, error) { // into dir/<commit of the pax header>, strip dropping the directory wrapping the files func extractTree(ctx context.Context, archive io.Reader, dir string, strip bool) (string, error) { // into dir/<commit of the pax header>, strip dropping the directory wrapping the files
temp, err := os.MkdirTemp(dir, "tmp-") temp, err := os.MkdirTemp(dir, "tmp-")
if err != nil { if err != nil {
return "", err return "", err
@@ -174,6 +228,7 @@ func extractTree(archive io.Reader, dir string, strip bool) (string, error) { //
defer root.Close() defer root.Close()
sha := "" sha := ""
sizes := map[string]int64{}
reader := tar.NewReader(archive) reader := tar.NewReader(archive)
for { for {
header, err := reader.Next() header, err := reader.Next()
@@ -184,7 +239,7 @@ func extractTree(archive io.Reader, dir string, strip bool) (string, error) { //
return "", err return "", err
} }
if header.Typeflag == tar.TypeXGlobalHeader { if header.Typeflag == tar.TypeXGlobalHeader {
if sha = header.PAXRecords["comment"]; isFullObjectID(sha) && exists(filepath.Join(dir, sha)) { if sha = header.PAXRecords["comment"]; isFullObjectID(sha) && treeIntact(dir, sha) {
return sha, nil return sha, nil
} }
continue continue
@@ -200,10 +255,10 @@ func extractTree(archive io.Reader, dir string, strip bool) (string, error) { //
case tar.TypeDir: case tar.TypeDir:
err = root.MkdirAll(name, 0o755) err = root.MkdirAll(name, 0o755)
case tar.TypeReg: case tar.TypeReg:
err = writeTreeFile(root, name, header.Mode, reader) sizes[name], err = writeTreeFile(root, name, header.Mode, reader)
case tar.TypeSymlink: case tar.TypeSymlink:
if err = root.Symlink(header.Linkname, name); err != nil && runtime.GOOS == "windows" { if err = root.Symlink(header.Linkname, name); err != nil && runtime.GOOS == "windows" {
err = writeTreeFile(root, name, 0, strings.NewReader(header.Linkname)) sizes[name], err = writeTreeFile(root, name, 0, strings.NewReader(header.Linkname))
} }
} }
if err != nil { if err != nil {
@@ -216,20 +271,76 @@ func extractTree(archive io.Reader, dir string, strip bool) (string, error) { //
if err := root.Close(); err != nil { if err := root.Close(); err != nil {
return "", err return "", err
} }
return sha, os.Rename(temp, filepath.Join(dir, sha)) return sha, installTree(ctx, dir, sha, temp, sizes)
} }
func writeTreeFile(root *os.Root, name string, mode int64, content io.Reader) error { var lockTree = AcquireCloneLock // replaced in tests to see installTree wait for a job holding the tree
// installTree moves temp into place as dir/sha, replacing a tree that failed treeIntact.
func installTree(ctx context.Context, dir, sha, temp string, sizes map[string]int64) error {
files := make([]treeFile, 0, len(sizes))
for name, size := range sizes {
files = append(files, treeFile{[]byte(name), size})
}
data, err := json.Marshal(files)
if err != nil {
return err
}
// Written before the swap: it describes the commit, not a copy of it, so it holds for whichever tree is left.
if err := os.WriteFile(filepath.Join(dir, sha+treeSizes), data, 0o600); err != nil { // a torn record fails treeIntact, so it needs no rename
return err
}
tree := filepath.Join(dir, sha)
if !exists(tree) {
return os.Rename(temp, tree)
}
stale, err := os.MkdirTemp(dir, "tmp-")
if err != nil {
return err
}
defer os.RemoveAll(stale) // after the unlock, as no job reads the old tree once it is moved
unlock, err := lockTree(ctx, tree) // jobs copying the old tree hold it
if err != nil {
return err
}
defer unlock()
if err := os.Rename(tree, filepath.Join(stale, sha)); err != nil {
return err
}
return os.Rename(temp, tree)
}
// treeIntact reports whether dir/sha still holds each file extracted into it at the size it was written.
// Only sizes are compared, not content: a stat per file is cheap enough for every job, hashing is not.
func treeIntact(dir, sha string) bool {
data, err := os.ReadFile(filepath.Join(dir, sha+treeSizes))
if err != nil || !exists(filepath.Join(dir, sha)) {
return false
}
var files []treeFile
if json.Unmarshal(data, &files) != nil {
return false
}
for _, file := range files {
info, err := os.Lstat(filepath.Join(dir, sha, filepath.FromSlash(string(file.Name))))
if err != nil || !info.Mode().IsRegular() || info.Size() != file.Size {
return false
}
}
return true
}
func writeTreeFile(root *os.Root, name string, mode int64, content io.Reader) (int64, error) {
perm := os.FileMode(0o644) perm := os.FileMode(0o644)
if mode&0o111 != 0 { if mode&0o111 != 0 {
perm = 0o755 perm = 0o755
} }
file, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm) file, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
if err != nil { if err != nil {
return err return 0, err
} }
_, err = io.Copy(file, content) n, err := io.Copy(file, content)
return errors.Join(err, file.Close()) return n, errors.Join(err, file.Close())
} }
func exists(name string) bool { func exists(name string) bool {
+197 -10
View File
@@ -6,6 +6,7 @@ package git
import ( import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
@@ -14,11 +15,15 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
"gitea.com/gitea/runner/act/common"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -37,6 +42,7 @@ func TestFetchAction(t *testing.T) {
require.NoError(t, gitCmd("-C", remote, "add", ".")) require.NoError(t, gitCmd("-C", remote, "add", "."))
require.NoError(t, gitCmd("-C", remote, "commit", "-m", "initial")) require.NoError(t, gitCmd("-C", remote, "commit", "-m", "initial"))
require.NoError(t, gitCmd("-C", remote, "tag", "v1")) require.NoError(t, gitCmd("-C", remote, "tag", "v1"))
expectedSha := gitRevParse(t, remote, "main")
gitPath, err := exec.LookPath("git") gitPath, err := exec.LookPath("git")
require.NoError(t, err) require.NoError(t, err)
@@ -44,6 +50,7 @@ func TestFetchAction(t *testing.T) {
var mu sync.Mutex var mu sync.Mutex
var archiveRequests []http.Header var archiveRequests []http.Header
var mirror *httptest.Server var mirror *httptest.Server
archiveCache := map[string][]byte{}
requests, archives, failures := 0, "", 0 requests, archives, failures := 0, "", 0
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
mu.Lock() mu.Lock()
@@ -80,7 +87,12 @@ func TestFetchAction(t *testing.T) {
if archives != "unprefixed" { if archives != "unprefixed" {
args = append(args, "--prefix=action/") args = append(args, "--prefix=action/")
} }
archive, _ := runGit(request.Context(), remote, nil, args...) archiveKey := strings.Join(args, "\x00")
archive, ok := archiveCache[archiveKey]
if !ok {
archive, _ = runGit(request.Context(), remote, nil, args...)
archiveCache[archiveKey] = archive
}
writer.Header().Set("ETag", etag) writer.Header().Set("ETag", etag)
_, _ = writer.Write(archive) _, _ = writer.Write(archive)
}) })
@@ -102,12 +114,80 @@ func TestFetchAction(t *testing.T) {
return NewGitCloneExecutorInput{URL: server.URL + "/org/action", Ref: ref, Dir: t.TempDir(), Token: "secret"} return NewGitCloneExecutorInput{URL: server.URL + "/org/action", Ref: ref, Dir: t.TempDir(), Token: "secret"}
} }
t.Run("shares identical fetches and isolates cancellation, panics and differing inputs", func(t *testing.T) {
for _, scenario := range []string{"shared", "leader cancelled", "leader panics", "different token"} {
t.Run(scenario, func(t *testing.T) {
serve("prefixed", 0)
input := newInput("main")
started, release := make(chan struct{}), make(chan struct{})
releaseFetches := sync.OnceFunc(func() { close(release) })
t.Cleanup(releaseFetches)
var once sync.Once
var panicked atomic.Bool
transport := actionFetchTransport(func(request *http.Request) (*http.Response, error) {
once.Do(func() { close(started) })
select {
case <-request.Context().Done():
return nil, request.Context().Err()
case <-release:
}
if scenario == "leader panics" && panicked.CompareAndSwap(false, true) {
panic("broken archive")
}
return http.DefaultTransport.RoundTrip(request)
})
input.Transport = &transport
leaderCtx, cancelLeader := context.WithCancel(t.Context())
defer cancelLeader()
waiting := &actionFetchContext{Context: t.Context(), waiting: make(chan struct{})}
followerInput := input
expectedRequests := 1
if scenario == "different token" {
followerInput.Token, expectedRequests = "other", 2
}
leaderDone, followerDone := make(chan error, 1), make(chan error, 1)
fetch := func(ctx context.Context, input NewGitCloneExecutorInput, done chan<- error) {
var err error
defer func() { done <- err }()
defer common.RecoverPanic(&err)
var tree, sha string
if tree, sha, err = FetchAction(ctx, input); err == nil {
assert.Equal(t, expectedSha, sha)
assert.Equal(t, filepath.Join(input.Dir, sha), tree)
}
}
go fetch(leaderCtx, input, leaderDone)
<-started
go fetch(waiting, followerInput, followerDone)
<-waiting.waiting
if scenario == "leader cancelled" {
cancelLeader()
require.ErrorIs(t, <-leaderDone, context.Canceled)
}
releaseFetches()
switch scenario {
case "leader cancelled":
case "leader panics":
require.ErrorContains(t, <-leaderDone, "panic: broken archive")
default:
require.NoError(t, <-leaderDone)
}
require.NoError(t, <-followerDone)
_, headers := served()
assert.Len(t, headers, expectedRequests)
if scenario == "different token" {
assert.NotEqual(t, headers[0].Get("Authorization"), headers[1].Get("Authorization"))
}
})
}
})
t.Run("downloads, revalidates and follows a moved tag", func(t *testing.T) { t.Run("downloads, revalidates and follows a moved tag", func(t *testing.T) {
serve("prefixed", 0) serve("prefixed", 0)
input := newInput("v1") input := newInput("v1")
tree, sha, err := FetchAction(t.Context(), input) tree, sha, err := FetchAction(t.Context(), input)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, gitRevParse(t, remote, "v1"), sha) assert.Equal(t, expectedSha, sha)
assert.Equal(t, filepath.Join(input.Dir, sha), tree) assert.Equal(t, filepath.Join(input.Dir, sha), tree)
assert.NoDirExists(t, filepath.Join(tree, ".git")) assert.NoDirExists(t, filepath.Join(tree, ".git"))
info, err := os.Stat(filepath.Join(tree, "run.sh")) info, err := os.Stat(filepath.Join(tree, "run.sh"))
@@ -123,9 +203,10 @@ func TestFetchAction(t *testing.T) {
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved")) require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1")) require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
expectedSha = gitRevParse(t, remote, "v1")
moved, movedSha, err := FetchAction(t.Context(), input) moved, movedSha, err := FetchAction(t.Context(), input)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, gitRevParse(t, remote, "v1"), movedSha) assert.Equal(t, expectedSha, movedSha)
assert.NotEqual(t, tree, moved) assert.NotEqual(t, tree, moved)
assert.FileExists(t, filepath.Join(tree, "action.yml")) assert.FileExists(t, filepath.Join(tree, "action.yml"))
@@ -133,10 +214,17 @@ func TestFetchAction(t *testing.T) {
_, offlineSha, err := FetchAction(t.Context(), input) _, offlineSha, err := FetchAction(t.Context(), input)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, movedSha, offlineSha) assert.Equal(t, movedSha, offlineSha)
require.NoError(t, os.Truncate(filepath.Join(moved, "action.yml"), 4)) // fetched again, offline or not
_, _, err = FetchAction(t.Context(), input)
require.NoError(t, err)
require.NoError(t, os.Remove(moved+treeSizes)) // from an older runner, used as it is offline
reused, _, err := FetchAction(t.Context(), input)
require.NoError(t, err)
assert.Equal(t, moved, reused)
requests, headers := served() requests, headers := served()
assert.Equal(t, 3, requests) assert.Equal(t, 4, requests)
require.Len(t, headers, 3) require.Len(t, headers, 4)
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), headers[0].Get("Authorization")) assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), headers[0].Get("Authorization"))
assert.Empty(t, headers[0].Get("If-None-Match")) assert.Empty(t, headers[0].Get("If-None-Match"))
assert.Equal(t, fmt.Sprintf(`W/"%s-tar.gz-true"`, sha), headers[1].Get("If-None-Match")) assert.Equal(t, fmt.Sprintf(`W/"%s-tar.gz-true"`, sha), headers[1].Get("If-None-Match"))
@@ -145,7 +233,7 @@ func TestFetchAction(t *testing.T) {
t.Run("downloads a pinned commit once and rejects a short one", func(t *testing.T) { t.Run("downloads a pinned commit once and rejects a short one", func(t *testing.T) {
serve("prefixed", 0) serve("prefixed", 0)
sha := gitRevParse(t, remote, "main") sha := expectedSha
input := newInput(sha) input := newInput(sha)
for range 2 { for range 2 {
tree, got, err := FetchAction(t.Context(), input) tree, got, err := FetchAction(t.Context(), input)
@@ -160,12 +248,74 @@ func TestFetchAction(t *testing.T) {
assert.NoDirExists(t, filepath.Join(input.Dir, "git")) assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
}) })
t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) {
sha := gitRevParse(t, remote, "main")
for _, mode := range []string{"prefixed", "none"} {
serve(mode, 0)
input := newInput(sha)
tree, _, err := FetchAction(t.Context(), input)
require.NoError(t, err, mode)
yml := filepath.Join(tree, "action.yml")
require.NoError(t, os.WriteFile(filepath.Join(tree, "trampoline.js"), nil, 0o644)) // the runner's own, no damage
_, _, err = FetchAction(t.Context(), input)
require.NoError(t, err, mode)
for _, damage := range []func() error{
func() error { return os.Truncate(yml, 4) },
func() error { return os.Remove(tree + treeSizes) },
} {
require.NoError(t, damage(), mode)
_, _, err := FetchAction(t.Context(), input)
require.NoError(t, err, mode)
body, err := os.ReadFile(yml)
require.NoError(t, err, mode)
assert.Equal(t, "name: action\n", string(body), mode)
}
_, headers := served()
assert.Len(t, headers, 3, mode)
}
serve("prefixed", 0)
input := newInput(sha)
tree, _, err := FetchAction(t.Context(), input)
require.NoError(t, err)
yml := filepath.Join(tree, "action.yml")
require.NoError(t, os.Truncate(yml, 4))
unlock, err := AcquireCloneLock(t.Context(), tree)
require.NoError(t, err)
release := sync.OnceFunc(unlock)
defer release()
waiting := make(chan struct{})
lockTree = func(ctx context.Context, dir string) (func(), error) {
close(waiting)
return AcquireCloneLock(ctx, dir)
}
t.Cleanup(func() { lockTree = AcquireCloneLock })
done := make(chan error, 1)
go func() {
_, _, err := FetchAction(t.Context(), input)
done <- err
}()
select {
case err := <-done:
t.Fatalf("FetchAction replaced the tree without waiting for the job holding it: %v", err)
case <-waiting:
}
info, err := os.Stat(yml)
require.NoError(t, err)
assert.Equal(t, int64(4), info.Size(), "the tree a job holds is left alone")
release()
require.NoError(t, <-done)
body, err := os.ReadFile(yml)
require.NoError(t, err)
assert.Equal(t, "name: action\n", string(body))
})
t.Run("retries a failing download and drops the token on a redirect to another origin", func(t *testing.T) { t.Run("retries a failing download and drops the token on a redirect to another origin", func(t *testing.T) {
serve("redirect", 2) serve("redirect", 2)
input := newInput("main") input := newInput("main")
_, sha, err := FetchAction(t.Context(), input) _, sha, err := FetchAction(t.Context(), input)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, gitRevParse(t, remote, "main"), sha) assert.Equal(t, expectedSha, sha)
assert.NoDirExists(t, filepath.Join(input.Dir, "git")) assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
_, headers := served() _, headers := served()
require.Len(t, headers, 4) require.Len(t, headers, 4)
@@ -178,7 +328,7 @@ func TestFetchAction(t *testing.T) {
serve(mode, 0) serve(mode, 0)
tree, sha, err := FetchAction(t.Context(), newInput("main")) tree, sha, err := FetchAction(t.Context(), newInput("main"))
require.NoError(t, err, mode) require.NoError(t, err, mode)
assert.Equal(t, gitRevParse(t, remote, "main"), sha, mode) assert.Equal(t, expectedSha, sha, mode)
assert.FileExists(t, filepath.Join(tree, "action.yml"), mode) assert.FileExists(t, filepath.Join(tree, "action.yml"), mode)
assert.NoDirExists(t, filepath.Join(tree, ".git"), mode) assert.NoDirExists(t, filepath.Join(tree, ".git"), mode)
_, headers := served() _, headers := served()
@@ -193,13 +343,30 @@ func TestFetchAction(t *testing.T) {
input.URL = "https://example.invalid/org/action" input.URL = "https://example.invalid/org/action"
_, sha, err := FetchAction(t.Context(), input) _, sha, err := FetchAction(t.Context(), input)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, gitRevParse(t, remote, "main"), sha) assert.Equal(t, expectedSha, sha)
_, headers := served() _, headers := served()
assert.Len(t, headers, 1) assert.Len(t, headers, 1)
assert.NoDirExists(t, filepath.Join(input.Dir, "git")) assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
}) })
} }
type actionFetchTransport func(*http.Request) (*http.Response, error)
func (transport actionFetchTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return transport(request)
}
type actionFetchContext struct {
context.Context
waiting chan struct{}
once sync.Once
}
func (ctx *actionFetchContext) Done() <-chan struct{} {
ctx.once.Do(func() { close(ctx.waiting) })
return ctx.Context.Done()
}
func TestExtractTreeConfinesEntries(t *testing.T) { func TestExtractTreeConfinesEntries(t *testing.T) {
outside := t.TempDir() outside := t.TempDir()
for name, entries := range map[string][]*tar.Header{ for name, entries := range map[string][]*tar.Header{
@@ -216,10 +383,30 @@ func TestExtractTreeConfinesEntries(t *testing.T) {
require.NoError(t, writer.Close()) require.NoError(t, writer.Close())
dir := t.TempDir() dir := t.TempDir()
_, err := extractTree(&archive, dir, false) _, err := extractTree(t.Context(), &archive, dir, false)
require.Error(t, err) require.Error(t, err)
assert.NoFileExists(t, filepath.Join(dir, "escape")) assert.NoFileExists(t, filepath.Join(dir, "escape"))
assert.NoFileExists(t, filepath.Join(outside, "escape")) assert.NoFileExists(t, filepath.Join(outside, "escape"))
}) })
} }
} }
func TestTreeIntactFindsNamesThatAreNotUTF8(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("macOS and Windows store no name that is not UTF-8")
}
sha := strings.Repeat("a", 40)
var archive bytes.Buffer
writer := tar.NewWriter(&archive)
require.NoError(t, writer.WriteHeader(&tar.Header{Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": sha}}))
require.NoError(t, writer.WriteHeader(&tar.Header{Name: "caf\xe9.js", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1, Format: tar.FormatGNU}))
_, err := writer.Write([]byte("x"))
require.NoError(t, err)
require.NoError(t, writer.Close())
dir := t.TempDir()
_, err = extractTree(t.Context(), &archive, dir, false)
require.NoError(t, err)
require.FileExists(t, filepath.Join(dir, sha, "caf\xe9.js"))
assert.True(t, treeIntact(dir, sha))
}
+24 -9
View File
@@ -10,6 +10,7 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -79,25 +80,25 @@ func (e *Error) Unwrap() error { return e.err }
func (e *Error) Commit() string { return e.commit } func (e *Error) Commit() string { return e.commit }
// FindGitRevision get the current git revision // FindGitRevision get the current git revision
func FindGitRevision(ctx context.Context, file string) (shortSHA, sha string, err error) { func FindGitRevision(ctx context.Context, file string) (string, error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
sha, err = gitOutput(ctx, file, "rev-parse", "--verify", "HEAD^{commit}") sha, err := gitOutput(ctx, file, "rev-parse", "--verify", "HEAD^{commit}")
if err != nil { if err != nil {
logger.WithError(err).Error("path", file, "not located inside a git repository") logger.WithError(err).Error("path", file, "not located inside a git repository")
return "", "", err return "", err
} }
if len(sha) < 7 { if len(sha) < 7 {
return "", "", errors.New("HEAD could not be resolved") return "", errors.New("HEAD could not be resolved")
} }
logger.Debugf("Found revision: %s", sha) logger.Debugf("Found revision: %s", sha)
return sha[:7], sha, nil return sha, nil
} }
// FindGitRef get the current git ref // FindGitRef get the current git ref
func FindGitRef(ctx context.Context, file string) (string, error) { func FindGitRef(ctx context.Context, file string) (string, error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
logger.Debugf("Loading revision from git directory") logger.Debugf("Loading revision from git directory")
_, revision, err := FindGitRevision(ctx, file) revision, err := FindGitRevision(ctx, file)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -320,9 +321,18 @@ func cachedRef(ref string) string {
func resolveRef(ctx context.Context, dir, ref string) (string, error) { func resolveRef(ctx context.Context, dir, ref string) (string, error) {
if !isFullObjectID(ref) { if !isFullObjectID(ref) {
for _, name := range []string{cachedRef(ref), "refs/tags/" + ref, "refs/remotes/origin/" + ref} { revisions := []string{cachedRef(ref) + "^{commit}", "refs/tags/" + ref + "^{commit}", "refs/remotes/origin/" + ref + "^{commit}"}
if sha, err := gitOutput(ctx, dir, "rev-parse", "--verify", name+"^{commit}"); err == nil { if strings.ContainsAny(ref, "\x00\n") {
return sha, nil for _, revision := range revisions {
if sha, err := gitOutput(ctx, dir, "rev-parse", "--verify", revision); err == nil {
return sha, nil
}
}
} else if output, err := runGitWithStdin(ctx, dir, nil, strings.NewReader(strings.Join(revisions, "\n")+"\n"), "cat-file", "--batch-check=%(objectname)"); err == nil {
for sha := range strings.SplitSeq(string(output), "\n") {
if isFullObjectID(sha) {
return sha, nil
}
} }
} }
} }
@@ -427,6 +437,10 @@ func ListFiles(ctx context.Context, dir string) ([]string, error) {
} }
func runGit(ctx context.Context, dir string, input *NewGitCloneExecutorInput, args ...string) ([]byte, error) { func runGit(ctx context.Context, dir string, input *NewGitCloneExecutorInput, args ...string) ([]byte, error) {
return runGitWithStdin(ctx, dir, input, nil, args...)
}
func runGitWithStdin(ctx context.Context, dir string, input *NewGitCloneExecutorInput, stdin io.Reader, args ...string) ([]byte, error) {
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
if input != nil { if input != nil {
config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...) config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...)
@@ -438,6 +452,7 @@ func runGit(ctx context.Context, dir string, input *NewGitCloneExecutorInput, ar
args = append([]string{"-C", dir}, args...) args = append([]string{"-C", dir}, args...)
} }
cmd := exec.CommandContext(ctx, "git", args...) cmd := exec.CommandContext(ctx, "git", args...)
cmd.Stdin = stdin
cmd.Env = slices.DeleteFunc(os.Environ(), func(entry string) bool { cmd.Env = slices.DeleteFunc(os.Environ(), func(entry string) bool {
name, _, _ := strings.Cut(entry, "=") name, _, _ := strings.Cut(entry, "=")
return slices.Contains(LocalEnvVars, name) return slices.Contains(LocalEnvVars, name)
+120 -172
View File
@@ -29,6 +29,31 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var gitFixtureDir string
func TestMain(m *testing.M) {
var err error
gitFixtureDir, err = os.MkdirTemp("", "runner-git-fixture-")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
code := 1
if err = gitCmd("init", "--quiet", "--template=", "--bare", "--initial-branch=main", filepath.Join(gitFixtureDir, "remote")); err == nil {
err = gitCmd("init", "--quiet", "--template=", "--initial-branch=main", filepath.Join(gitFixtureDir, "work"))
}
if err == nil {
code = m.Run()
} else {
fmt.Fprintln(os.Stderr, err)
}
if err := os.RemoveAll(gitFixtureDir); err != nil {
fmt.Fprintln(os.Stderr, err)
code = 1
}
os.Exit(code)
}
func TestFindGitSlug(t *testing.T) { func TestFindGitSlug(t *testing.T) {
for _, testcase := range []struct { for _, testcase := range []struct {
remoteURL string remoteURL string
@@ -67,42 +92,20 @@ func TestErrorWrapsCommitAndCause(t *testing.T) {
require.Equal(t, "abc123", err.Commit()) require.Equal(t, "abc123", err.Commit())
} }
func cleanGitHooks(dir string) error {
hooksDir := filepath.Join(dir, ".git", "hooks")
files, err := os.ReadDir(hooksDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, f := range files {
if f.IsDir() {
continue
}
relName := filepath.Join(hooksDir, f.Name())
if err := os.Remove(relName); err != nil {
return err
}
}
return nil
}
func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) { func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
t.Parallel() t.Parallel()
basedir := t.TempDir() basedir := t.TempDir()
const remoteURL = "https://github.com/owner/repo.git" const remoteURL = "https://github.com/owner/repo.git"
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir)) require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir))
require.NoError(t, cleanGitHooks(basedir)) require.NoError(t, os.RemoveAll(filepath.Join(basedir, ".git", "hooks")))
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init")) require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)) require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git")) require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
shortSHA, sha, err := FindGitRevision(t.Context(), basedir) sha, err := FindGitRevision(t.Context(), basedir)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha) require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha)
require.Len(t, sha, 64) require.Len(t, sha, 64)
require.Equal(t, sha[:7], shortSHA)
ref, err := FindGitRef(t.Context(), basedir) ref, err := FindGitRef(t.Context(), basedir)
require.NoError(t, err) require.NoError(t, err)
@@ -122,89 +125,65 @@ func TestGitFindRef(t *testing.T) {
basedir := t.TempDir() basedir := t.TempDir()
for name, tt := range map[string]struct { for name, tt := range map[string]struct {
Prepare func(t *testing.T, dir string) commands [][]string
Assert func(t *testing.T, ref string, err error) ref string
}{ }{
"new_repo": { "new_repo": {},
Prepare: func(t *testing.T, dir string) {},
Assert: func(t *testing.T, ref string, err error) {
require.Error(t, err)
},
},
"new_repo_with_commit": { "new_repo_with_commit": {
Prepare: func(t *testing.T, dir string) { commands: [][]string{{"commit", "--allow-empty", "-m", "msg"}},
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg")) ref: "refs/heads/master",
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/heads/master", ref)
},
}, },
"current_head_is_tag": { "current_head_is_tag": {
Prepare: func(t *testing.T, dir string) { commands: [][]string{
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "commit msg")) {"commit", "--allow-empty", "-m", "commit msg"},
require.NoError(t, gitCmd("-C", dir, "tag", "v1.2.3")) {"tag", "v1.2.3"},
require.NoError(t, gitCmd("-C", dir, "checkout", "v1.2.3")) {"checkout", "v1.2.3"},
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/tags/v1.2.3", ref)
}, },
ref: "refs/tags/v1.2.3",
}, },
"current_head_is_same_as_tag": { "current_head_is_same_as_tag": {
Prepare: func(t *testing.T, dir string) { commands: [][]string{
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "1.4.2 release")) {"commit", "--allow-empty", "-m", "1.4.2 release"},
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2")) {"tag", "v1.4.2"},
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/tags/v1.4.2", ref)
}, },
ref: "refs/tags/v1.4.2",
}, },
"current_head_is_not_tag": { "current_head_is_not_tag": {
Prepare: func(t *testing.T, dir string) { commands: [][]string{
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg")) {"commit", "--allow-empty", "-m", "msg"},
require.NoError(t, gitCmd("-C", dir, "tag", "v1.4.2")) {"tag", "v1.4.2"},
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg2")) {"commit", "--allow-empty", "-m", "msg2"},
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/heads/master", ref)
}, },
ref: "refs/heads/master",
}, },
"current_head_is_another_branch": { "current_head_is_another_branch": {
Prepare: func(t *testing.T, dir string) { commands: [][]string{
require.NoError(t, gitCmd("-C", dir, "checkout", "-b", "mybranch")) {"checkout", "-b", "mybranch"},
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", "msg")) {"commit", "--allow-empty", "-m", "msg"},
},
Assert: func(t *testing.T, ref string, err error) {
require.NoError(t, err)
require.Equal(t, "refs/heads/mybranch", ref)
}, },
ref: "refs/heads/mybranch",
}, },
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
dir := filepath.Join(basedir, name) dir := filepath.Join(basedir, name)
require.NoError(t, os.MkdirAll(dir, 0o755)) require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master")) require.NoError(t, os.WriteFile(filepath.Join(dir, ".git", "HEAD"), []byte("ref: refs/heads/master\n"), 0o644))
require.NoError(t, cleanGitHooks(dir)) for _, command := range tt.commands {
tt.Prepare(t, dir) require.NoError(t, gitCmd(append([]string{"-C", dir}, command...)...))
}
ref, err := FindGitRef(t.Context(), dir) ref, err := FindGitRef(t.Context(), dir)
tt.Assert(t, ref, err) if tt.ref == "" {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.ref, ref)
}
}) })
} }
} }
func TestGitCloneExecutor(t *testing.T) { func TestGitCloneExecutor(t *testing.T) {
// Build a local bare "remote" so this runs offline and fast. The cases below mirror remoteDir, workDir := newGitRemote(t)
// the tag/branch/sha/short-sha ref paths the executor handles, formerly exercised by
// cloning actions/checkout and anchore/scan-action over the network.
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, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755))
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh")) require.NoError(t, gitCmd("-C", workDir, "add", "action.sh"))
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial")) require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
@@ -212,14 +191,11 @@ func TestGitCloneExecutor(t *testing.T) {
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", "v2")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
// A branch with a dash in the name (mirrors the historical scan-action@act-fails case).
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails")) require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit")) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output() fullSha := gitRevParse(t, workDir, "main")
require.NoError(t, err)
fullSha := strings.TrimSpace(string(out))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4])) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4]))
@@ -228,6 +204,7 @@ func TestGitCloneExecutor(t *testing.T) {
t.Setenv("GIT_CONFIG_GLOBAL", hostConfig) t.Setenv("GIT_CONFIG_GLOBAL", hostConfig)
t.Setenv("GIT_DIR", t.TempDir()) t.Setenv("GIT_DIR", t.TempDir())
var clones sync.WaitGroup
for name, tt := range map[string]struct { for name, tt := range map[string]struct {
Err error Err error
Ref string Ref string
@@ -255,43 +232,40 @@ func TestGitCloneExecutor(t *testing.T) {
Ref: fullSha[:4], Ref: fullSha[:4],
}, },
} { } {
t.Run(name, func(t *testing.T) { clones.Go(func() {
dir := t.TempDir() t.Run(name, func(t *testing.T) {
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{ dir := t.TempDir()
URL: "https://example.invalid/action", clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
Ref: tt.Ref, URL: "https://example.invalid/action",
Dir: dir, Ref: tt.Ref,
}) Dir: dir,
})
err := clone(t.Context()) err := clone(t.Context())
if tt.Err != nil { if tt.Err != nil {
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act require.Error(t, err)
assert.Equal(t, tt.Err, err) assert.Equal(t, tt.Err, err)
assert.Equal(t, tt.Err, clone(t.Context()), "a retry on the cache keeps the error") assert.Equal(t, tt.Err, clone(t.Context()), "a retry on the cache keeps the error")
return return
} }
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
marker := filepath.Join(dir, "marker") marker := filepath.Join(dir, "marker")
require.NoError(t, os.WriteFile(marker, nil, 0o644)) require.NoError(t, os.WriteFile(marker, nil, 0o644))
require.NoError(t, clone(t.Context())) require.NoError(t, clone(t.Context()))
assert.FileExists(t, marker, "an insteadOf rewrite must not evict the cache") assert.FileExists(t, marker, "an insteadOf rewrite must not evict the cache")
script, err := os.ReadFile(filepath.Join(dir, "action.sh")) script, err := os.ReadFile(filepath.Join(dir, "action.sh"))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "#!/bin/sh\necho hi\n", string(script), "host core.autocrlf must not convert action files") assert.Equal(t, "#!/bin/sh\necho hi\n", string(script), "host core.autocrlf must not convert action files")
})
}) })
} }
clones.Wait()
} }
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) { func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
t.Parallel() t.Parallel()
createRemote := func(message string) string { createRemote := func(message string) string {
remoteDir := t.TempDir() remoteDir, workDir := newGitRemote(t, message)
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", message))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
return remoteDir return remoteDir
@@ -341,13 +315,7 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) { func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) {
t.Parallel() t.Parallel()
remoteDir := t.TempDir() remoteDir, workDir := newGitRemote(t, "initial", "feature-1")
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", "initial"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
dir := t.TempDir() dir := t.TempDir()
@@ -380,16 +348,9 @@ func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) {
func TestGitCloneExecutorOfflineMode(t *testing.T) { func TestGitCloneExecutorOfflineMode(t *testing.T) {
t.Parallel() t.Parallel()
// Build a local "remote" with a single commit on main. remoteDir, workDir := newGitRemote(t, "initial")
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", "initial"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
// Prime the cache with an online clone of main.
cacheDir := t.TempDir() cacheDir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, URL: remoteDir,
@@ -398,8 +359,6 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
})(t.Context())) })(t.Context()))
t.Run("cached branch resolves without fetching", func(t *testing.T) { t.Run("cached branch resolves without fetching", func(t *testing.T) {
// Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref)
// finds no local refs/heads/<ref>.
err := NewGitCloneExecutor(NewGitCloneExecutorInput{ err := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, URL: remoteDir,
Ref: "main", Ref: "main",
@@ -414,7 +373,6 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
}) })
t.Run("unresolvable cached ref returns error", func(t *testing.T) { t.Run("unresolvable cached ref returns error", func(t *testing.T) {
// The ref was never cached; offline mode cannot resolve it and must return an error.
err := NewGitCloneExecutor(NewGitCloneExecutorInput{ err := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, URL: remoteDir,
Ref: "never-fetched", Ref: "never-fetched",
@@ -427,15 +385,7 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
func TestGitCloneExecutorShallow(t *testing.T) { func TestGitCloneExecutorShallow(t *testing.T) {
t.Parallel() t.Parallel()
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one. remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3")
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"))
for _, m := range []string{"c1", "c2", "c3"} {
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"))
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1") 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"))
@@ -443,25 +393,17 @@ func TestGitCloneExecutorShallow(t *testing.T) {
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") } shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
t.Run("branch is cloned shallowly", func(t *testing.T) { for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} {
dir := t.TempDir() t.Run(name, func(t *testing.T) {
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ dir := t.TempDir()
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1, require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
})(t.Context())) URL: remoteDir, Ref: ref, Dir: dir, Depth: 1,
assert.FileExists(t, shallowMarker(dir), "clone should be shallow") })(t.Context()))
assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present") assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
assert.Equal(t, "c3", gitHeadSubject(t, dir)) assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present")
}) assert.Equal(t, "c3", gitHeadSubject(t, dir))
})
t.Run("tag is cloned shallowly", func(t *testing.T) { }
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: "v1", Dir: dir, Depth: 1,
})(t.Context()))
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
assert.Equal(t, 1, gitRevCount(t, dir))
assert.Equal(t, "c3", gitHeadSubject(t, dir))
})
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string { unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
remote := filepath.Join(t.TempDir(), "remote.git") remote := filepath.Join(t.TempDir(), "remote.git")
@@ -541,12 +483,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) { func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
t.Parallel() t.Parallel()
remoteDir := t.TempDir() remoteDir, workDir := newGitRemote(t, "pinned")
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") pinned := gitRevParse(t, workDir, "HEAD")
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy")) 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, "tag", pinned))
@@ -564,6 +501,21 @@ func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
} }
} }
func newGitRemote(t *testing.T, messages ...string) (string, string) {
t.Helper()
remoteDir, workDir := t.TempDir(), t.TempDir()
require.NoError(t, os.CopyFS(remoteDir, os.DirFS(filepath.Join(gitFixtureDir, "remote"))))
require.NoError(t, os.CopyFS(workDir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
config, err := os.ReadFile(filepath.Join(workDir, ".git", "config"))
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(workDir, ".git", "config"),
fmt.Appendf(config, "\n[remote \"origin\"]\n\turl = %s\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", strconv.Quote(remoteDir)), 0o644))
for _, message := range messages {
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", message))
}
return remoteDir, workDir
}
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()
@@ -591,9 +543,6 @@ func gitCmd(args ...string) error {
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
// Inject a deterministic identity and ignore the host's global/system config so commits
// succeed regardless of the host having no user.name/user.email (e.g. CI, GITHUB_ACTIONS
// unset) or a global commit.gpgsign, and without mutating the developer's ~/.gitconfig.
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Unit Test", "GIT_AUTHOR_NAME=Unit Test",
"[email protected]", "[email protected]",
@@ -695,7 +644,6 @@ func TestAcquireCloneLock(t *testing.T) {
}) })
} }
// An unresponsive remote must not pin a job: the refresh has to be interruptible.
func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) { func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
t.Parallel() t.Parallel()
block := make(chan struct{}) block := make(chan struct{})
+3 -9
View File
@@ -14,13 +14,8 @@ const jobErrorContextKeyVal = jobErrorContextKey("job.error")
// JobError returns the job error for current context if any // JobError returns the job error for current context if any
func JobError(ctx context.Context) error { func JobError(ctx context.Context) error {
val := ctx.Value(jobErrorContextKeyVal) container, _ := ctx.Value(jobErrorContextKeyVal).(map[string]error)
if val != nil { return container["error"]
if container, ok := val.(map[string]error); ok {
return container["error"]
}
}
return nil
} }
func SetJobError(ctx context.Context, err error) { func SetJobError(ctx context.Context, err error) {
@@ -31,6 +26,5 @@ func SetJobError(ctx context.Context, err error) {
// WithJobErrorContainer adds a value to the context as a container for an error // WithJobErrorContainer adds a value to the context as a container for an error
func WithJobErrorContainer(ctx context.Context) context.Context { func WithJobErrorContainer(ctx context.Context) context.Context {
container := map[string]error{} return context.WithValue(ctx, jobErrorContextKeyVal, map[string]error{})
return context.WithValue(ctx, jobErrorContextKeyVal, container)
} }
+9 -21
View File
@@ -26,9 +26,7 @@ type lineWriter struct {
// NewLineWriter creates a new instance of a line writer // NewLineWriter creates a new instance of a line writer
func NewLineWriter(handlers ...LineHandler) io.Writer { func NewLineWriter(handlers ...LineHandler) io.Writer {
w := new(lineWriter) return &lineWriter{handlers: handlers}
w.handlers = handlers
return w
} }
// FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so // FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so
@@ -39,24 +37,15 @@ func FlushWriter(w io.Writer) {
} }
} }
func (lw *lineWriter) Write(p []byte) (n int, err error) { func (lw *lineWriter) Write(p []byte) (int, error) {
pBuf := bytes.NewBuffer(p) for line := range bytes.SplitAfterSeq(p, []byte{'\n'}) {
written := 0 _, _ = lw.buffer.Write(line)
for { if len(line) > 0 && line[len(line)-1] == '\n' {
line, err := pBuf.ReadString('\n') lw.handleLine(lw.buffer.String())
w, _ := lw.buffer.WriteString(line) lw.buffer.Reset()
written += w
if err != nil {
if err == io.EOF {
break
}
return written, err
} }
lw.handleLine(lw.buffer.String())
lw.buffer.Reset()
} }
return len(p), nil
return written, nil
} }
// Flush emits any buffered, not-yet-newline-terminated content as a final line. // Flush emits any buffered, not-yet-newline-terminated content as a final line.
@@ -72,8 +61,7 @@ func (lw *lineWriter) Flush() {
func (lw *lineWriter) handleLine(line string) { func (lw *lineWriter) handleLine(line string) {
for _, h := range lw.handlers { for _, h := range lw.handlers {
ok := h(line) if !h(line) {
if !ok {
break break
} }
} }
+12 -34
View File
@@ -9,24 +9,24 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestLineWriter(t *testing.T) { func TestLineWriterBuffersAndFlushesLines(t *testing.T) {
lines := make([]string, 0) var lines []string
lineHandler := func(s string) bool { lineWriter := NewLineWriter(func(s string) bool {
lines = append(lines, s) lines = append(lines, s)
return true return true
} })
lineWriter := NewLineWriter(lineHandler)
assert := assert.New(t) assert := assert.New(t)
write := func(s string) { write := func(s string) {
n, err := lineWriter.Write([]byte(s)) n, err := lineWriter.Write([]byte(s))
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
assert.Equal(len(s), n, s) assert.Equal(len(s), n, s)
} }
write("")
write("hello") write("hello")
write(" ") write(" ")
write("world!!\nextra") write("world!!\nextra")
@@ -34,39 +34,17 @@ func TestLineWriter(t *testing.T) {
write(" line\n") write(" line\n")
write("no newline here...") write("no newline here...")
assert.Len(lines, 4) expected := []string{"hello world!!\n", "extra line\n", " and another\n", "last line\n"}
assert.Equal("hello world!!\n", lines[0]) assert.Equal(expected, lines)
assert.Equal("extra line\n", lines[1])
assert.Equal(" and another\n", lines[2])
assert.Equal("last line\n", lines[3])
}
func TestLineWriterFlush(t *testing.T) {
lines := make([]string, 0)
lineHandler := func(s string) bool {
lines = append(lines, s)
return true
}
lineWriter := NewLineWriter(lineHandler)
assert := assert.New(t)
_, err := lineWriter.Write([]byte("complete line\npartial line without newline"))
assert.NoError(err) //nolint:testifylint // pre-existing pattern from nektos/act
// Only the newline-terminated line is emitted before flushing.
assert.Equal([]string{"complete line\n"}, lines)
// Flushing emits the buffered, not-yet-terminated trailing line.
FlushWriter(lineWriter) FlushWriter(lineWriter)
assert.Equal([]string{"complete line\n", "partial line without newline"}, lines) expected = append(expected, "no newline here...")
assert.Equal(expected, lines)
// Flushing again is a no-op: nothing is buffered.
FlushWriter(lineWriter) FlushWriter(lineWriter)
assert.Len(lines, 2) assert.Equal(expected, lines)
} }
func TestFlushWriterIgnoresNonFlusher(t *testing.T) { func TestFlushWriterIgnoresNonFlusher(t *testing.T) {
// FlushWriter must be a safe no-op for writers that do not buffer lines.
assert.NotPanics(t, func() { FlushWriter(io.Discard) }) assert.NotPanics(t, func() { FlushWriter(io.Discard) })
} }
+2 -4
View File
@@ -34,10 +34,8 @@ const loggerHandlerKeyVal = loggerHandlerKey("logging.Handler")
// LoggerHandler returns the extra handler the job logger sends its records to, or nil. // LoggerHandler returns the extra handler the job logger sends its records to, or nil.
// It affects the job logger, not the global logger. // It affects the job logger, not the global logger.
func LoggerHandler(ctx context.Context) logging.Handler { func LoggerHandler(ctx context.Context) logging.Handler {
if h, ok := ctx.Value(loggerHandlerKeyVal).(logging.Handler); ok { h, _ := ctx.Value(loggerHandlerKeyVal).(logging.Handler)
return h return h
}
return nil
} }
// WithLoggerHandler adds the extra handler for the job logger to the context // WithLoggerHandler adds the extra handler for the job logger to the context
+2 -5
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"maps"
"reflect" "reflect"
"runtime" "runtime"
"slices" "slices"
@@ -94,11 +95,7 @@ func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
} }
prefixFieldClashes(data, caller != nil) prefixFieldClashes(data, caller != nil)
keys := make([]string, 0, len(data)) keys := slices.Sorted(maps.Keys(data))
for k := range data {
keys = append(keys, k)
}
slices.Sort(keys)
var b bytes.Buffer var b bytes.Buffer
if terminal && !opts.DisableColors { if terminal && !opts.DisableColors {
-2
View File
@@ -73,12 +73,10 @@ func Enabled(level Level) bool { return Default().Enabled(level) }
func With(args ...any) *Logger { return Default().With(args...) } func With(args ...any) *Logger { return Default().With(args...) }
func WithError(err error) *Logger { return Default().WithError(err) } func WithError(err error) *Logger { return Default().WithError(err) }
func Tracef(format string, args ...any) { Default().logf(LevelTrace, format, args) }
func Debugf(format string, args ...any) { Default().logf(LevelDebug, format, args) } func Debugf(format string, args ...any) { Default().logf(LevelDebug, format, args) }
func Infof(format string, args ...any) { Default().logf(LevelInfo, format, args) } func Infof(format string, args ...any) { Default().logf(LevelInfo, format, args) }
func Warnf(format string, args ...any) { Default().logf(LevelWarn, format, args) } func Warnf(format string, args ...any) { Default().logf(LevelWarn, format, args) }
func Errorf(format string, args ...any) { Default().logf(LevelError, format, args) } func Errorf(format string, args ...any) { Default().logf(LevelError, format, args) }
func Trace(args ...any) { Default().log(LevelTrace, args) }
func Debug(args ...any) { Default().log(LevelDebug, args) } func Debug(args ...any) { Default().log(LevelDebug, args) }
func Info(args ...any) { Default().log(LevelInfo, args) } func Info(args ...any) { Default().log(LevelInfo, args) }
func Warn(args ...any) { Default().log(LevelWarn, args) } func Warn(args ...any) { Default().log(LevelWarn, args) }
-1
View File
@@ -43,7 +43,6 @@ func (l *Logger) Enabled(level Level) bool {
return l.h.Enabled(l.ctx, level) return l.h.Enabled(l.ctx, level)
} }
func (l *Logger) Tracef(format string, args ...any) { l.logf(LevelTrace, format, args) }
func (l *Logger) Debugf(format string, args ...any) { l.logf(LevelDebug, format, args) } func (l *Logger) Debugf(format string, args ...any) { l.logf(LevelDebug, format, args) }
func (l *Logger) Infof(format string, args ...any) { l.logf(LevelInfo, format, args) } func (l *Logger) Infof(format string, args ...any) { l.logf(LevelInfo, format, args) }
func (l *Logger) Warnf(format string, args ...any) { l.logf(LevelWarn, format, args) } func (l *Logger) Warnf(format string, args ...any) { l.logf(LevelWarn, format, args) }
+3 -7
View File
@@ -38,6 +38,7 @@ func (c countingStringer) String() string { *c.n++; return "x" }
func TestLogger(t *testing.T) { func TestLogger(t *testing.T) {
h := logtest.NewWithLevel(logging.LevelTrace) h := logtest.NewWithLevel(logging.LevelTrace)
assert.Nil(t, h.LastRecord())
l := logging.New(h) l := logging.New(h)
l.With("job", "j1").WithError(errors.New("boom")).Warnf("step %d", 2) l.With("job", "j1").WithError(errors.New("boom")).Warnf("step %d", 2)
@@ -53,12 +54,9 @@ func TestLogger(t *testing.T) {
assert.Equal(t, "j1", job) assert.Equal(t, "j1", job)
errv, _ := recs[0].Attr("error") errv, _ := recs[0].Attr("error")
require.EqualError(t, errv.(error), "boom") require.EqualError(t, errv.(error), "boom")
assert.Equal(t, "a1 2b", recs[1].Message) // fmt.Sprint spacing assert.Equal(t, "a1 2b", recs[1].Message)
bad, _ := recs[2].Attr("!BADKEY") bad, _ := recs[2].Attr("!BADKEY")
assert.Equal(t, 7, bad) assert.Equal(t, 7, bad)
h.Reset()
assert.Nil(t, h.LastRecord())
} }
func TestLoggerSkipsFormattingWhenDisabled(t *testing.T) { func TestLoggerSkipsFormattingWhenDisabled(t *testing.T) {
@@ -117,10 +115,8 @@ func TestDefaultLevel(t *testing.T) {
func TestSlogHandler(t *testing.T) { func TestSlogHandler(t *testing.T) {
var buf strings.Builder var buf strings.Builder
slogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) l := logging.New(logging.NewSlogHandler(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))).With("job", "j1")
l := logging.New(logging.NewSlogHandler(slogger.Handler())).With("job", "j1")
l.Tracef("dropped")
assert.False(t, l.Enabled(logging.LevelTrace)) assert.False(t, l.Enabled(logging.LevelTrace))
l.WithError(errors.New("boom")).Errorf("fail %s", "x") l.WithError(errors.New("boom")).Errorf("fail %s", "x")
-6
View File
@@ -72,12 +72,6 @@ func (h *Handler) LastRecord() *logging.Record {
return &r return &r
} }
func (h *Handler) Reset() {
h.store.mu.Lock()
defer h.store.mu.Unlock()
h.store.records = nil
}
// SetDefault makes the process-wide Logger record into a new Handler until the test ends. // SetDefault makes the process-wide Logger record into a new Handler until the test ends.
func SetDefault(t testing.TB) *Handler { func SetDefault(t testing.TB) *Handler {
t.Helper() t.Helper()
+8 -12
View File
@@ -12,7 +12,7 @@ import (
type interfaceIP struct { type interfaceIP struct {
net.IP net.IP
net.Interface Name string
} }
// GetOutboundIP returns an outbound IP address of this machine. // GetOutboundIP returns an outbound IP address of this machine.
@@ -43,7 +43,7 @@ func GetOutboundIP() net.IP {
ip = v.IP ip = v.IP
} }
if ip.IsGlobalUnicast() { if ip.IsGlobalUnicast() {
ips = append(ips, interfaceIP{ip, i}) ips = append(ips, interfaceIP{ip, i.Name})
} }
} }
} }
@@ -59,29 +59,25 @@ func preferredIP(ips []interfaceIP) net.IP {
return nil return nil
} }
sort.Slice(ips, func(i, j int) bool { sort.Slice(ips, func(i, j int) bool {
ifi := ips[i].Interface first, second := ips[i], ips[j]
ifj := ips[j].Interface
// ethernet is preferred // ethernet is preferred
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj { if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
return vi return vi
} }
ipi := ips[i].IP
ipj := ips[j].IP
// IPv4 is preferred // IPv4 is preferred
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj { if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
return vi return vi
} }
// en0 is preferred to en1 // en0 is preferred to en1
if ifi.Name != ifj.Name { if first.Name != second.Name {
return ifi.Name < ifj.Name return first.Name < second.Name
} }
// fallback // fallback
return ipi.String() < ipj.String() return first.String() < second.String()
}) })
return ips[0].IP return ips[0].IP
} }
+2 -5
View File
@@ -46,9 +46,8 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
logger.Debugf("Building image from '%v'", input.ContextDir) logger.Debugf("Building image from '%v'", input.ContextDir)
tags := []string{input.ImageTag}
options := client.ImageBuildOptions{ options := client.ImageBuildOptions{
Tags: tags, Tags: []string{input.ImageTag},
Remove: true, Remove: true,
AuthConfigs: LoadDockerAuthConfigs(ctx), AuthConfigs: LoadDockerAuthConfigs(ctx),
Dockerfile: input.Dockerfile, Dockerfile: input.Dockerfile,
@@ -75,12 +74,10 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
logger.Debugf("Creating image from context dir '%s' with tag '%s' and platform '%s'", input.ContextDir, input.ImageTag, input.Platform) logger.Debugf("Creating image from context dir '%s' with tag '%s' and platform '%s'", input.ContextDir, input.ImageTag, input.Platform)
resp, err := cli.ImageBuild(ctx, buildContext, options) resp, err := cli.ImageBuild(ctx, buildContext, options)
err = logDockerResponse(logger, resp.Body, err != nil)
if err != nil { if err != nil {
return err return err
} }
return nil return logDockerResponse(logger, resp.Body, false)
} }
} }
+22
View File
@@ -8,13 +8,35 @@ package container
import ( import (
"archive/tar" "archive/tar"
"io" "io"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestDockerBuildReportsDaemonError(t *testing.T) {
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("API-Version", "1.47")
if strings.HasSuffix(request.URL.Path, "/build") {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(writer, `{"message":"build rejected"}`)
}
}))
t.Cleanup(daemon.Close)
t.Setenv("DOCKER_HOST", daemon.URL)
t.Setenv("DOCKER_CONFIG", t.TempDir())
t.Setenv("DOCKER_AUTH_CONFIG", "")
require.ErrorContains(t, NewDockerBuildExecutor(NewDockerBuildExecutorInput{
ImageTag: "action:test", Dockerfile: "Dockerfile", BuildContext: strings.NewReader(""),
})(t.Context()), "build rejected")
}
func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) { func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
for name, content := range map[string]string{ for name, content := range map[string]string{
+16 -54
View File
@@ -4,12 +4,12 @@
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)) //go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with: // This file is based on https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
// * appended with license information // * appended with license information
// * regexp and ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec // * regexp and ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
// * cdiDeviceName in place of the container-device-interface parser's IsQualifiedName // * cdiDeviceName in place of the container-device-interface parser's IsQualifiedName
// * jsontext in place of json.Compact for the seccomp profile // * jsontext in place of json.Compact for the seccomp profile
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go // * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for nat port types
// //
// docker/cli is licensed under the Apache License, Version 2.0. // docker/cli is licensed under the Apache License, Version 2.0.
// See DOCKER_LICENSE for the full license text. // See DOCKER_LICENSE for the full license text.
@@ -154,9 +154,6 @@ type containerOptions struct {
init bool init bool
umask opts.UmaskOpt umask opts.UmaskOpt
annotations *opts.MapOpts annotations *opts.MapOpts
Image string
Args []string
} }
// addFlags adds all command line flags that will be used by parse to the FlagSet // addFlags adds all command line flags that will be used by parse to the FlagSet
@@ -422,12 +419,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
tmpfs[k] = v tmpfs[k] = v
} }
var runCmd, entrypoint []string var entrypoint []string
if len(copts.Args) > 0 {
runCmd = copts.Args
}
if copts.entrypoint != "" { if copts.entrypoint != "" {
entrypoint = []string{copts.entrypoint} entrypoint = []string{copts.entrypoint}
} else if flags.Changed("entrypoint") { } else if flags.Changed("entrypoint") {
@@ -448,36 +440,15 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
if err != nil { if err != nil {
return nil, err return nil, err
} }
portBindings := network.PortMap{} portBindings, err := convertPortMap(natPortBindings)
for port, bindings := range natPortBindings { if err != nil {
p, err := network.ParsePort(string(port)) return nil, err
if err != nil {
return nil, err
}
portBindings[p] = []network.PortBinding{}
for _, b := range bindings {
var hostIP netip.Addr
if b.HostIP != "" {
hostIP, err = netip.ParseAddr(b.HostIP)
if err != nil {
return nil, err
}
}
portBindings[p] = append(portBindings[p], network.PortBinding{
HostIP: hostIP,
HostPort: b.HostPort,
})
}
} }
// Add published ports as exposed ports. // Add published ports as exposed ports.
exposedPorts := network.PortSet{} exposedPorts, err := convertPortSet(ports)
for port := range ports { if err != nil {
p, err := network.ParsePort(string(port)) return nil, err
if err != nil {
return nil, err
}
exposedPorts[p] = struct{}{}
} }
// Merge in exposed ports to the map of published ports // Merge in exposed ports to the map of published ports
@@ -618,11 +589,10 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
deviceRequests := copts.gpus.Value() deviceRequests := copts.gpus.Value()
if len(cdiDeviceNames) > 0 { if len(cdiDeviceNames) > 0 {
cdiDeviceRequest := container.DeviceRequest{ deviceRequests = append(deviceRequests, container.DeviceRequest{
Driver: "cdi", Driver: "cdi",
DeviceIDs: cdiDeviceNames, DeviceIDs: cdiDeviceNames,
} })
deviceRequests = append(deviceRequests, cdiDeviceRequest)
} }
resources := container.Resources{ resources := container.Resources{
@@ -668,8 +638,6 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
AttachStdout: attachStdout, AttachStdout: attachStdout,
AttachStderr: attachStderr, AttachStderr: attachStderr,
Env: envVariables, Env: envVariables,
Cmd: runCmd,
Image: copts.Image,
Volumes: volumes, Volumes: volumes,
Entrypoint: entrypoint, Entrypoint: entrypoint,
WorkingDir: copts.workingDir, WorkingDir: copts.workingDir,
@@ -810,10 +778,8 @@ func parseNetworkOpts(copts *containerOptions) (map[string]*network.EndpointSett
// For backward compatibility: if no custom options are provided for the network, // For backward compatibility: if no custom options are provided for the network,
// and only a single network is specified, omit the endpoint-configuration // and only a single network is specified, omit the endpoint-configuration
// on the client (the daemon will still create it when creating the container) // on the client (the daemon will still create it when creating the container)
if i == 0 && len(copts.netMode.Value()) == 1 { if i == 0 && len(copts.netMode.Value()) == 1 && reflect.ValueOf(*ep).IsZero() {
if ep == nil || reflect.ValueOf(*ep).IsZero() { continue
continue
}
} }
endpoints[n.Target] = ep endpoints[n.Target] = ep
} }
@@ -844,14 +810,12 @@ func applyContainerOptions(n *opts.NetworkAttachmentOpts, copts *containerOption
return invalidParameter(errors.New("conflicting options: cannot specify both --link-local-ip and per-network link-local IP addresses")) return invalidParameter(errors.New("conflicting options: cannot specify both --link-local-ip and per-network link-local IP addresses"))
} }
if copts.aliases.Len() > 0 { if copts.aliases.Len() > 0 {
n.Aliases = make([]string, copts.aliases.Len()) n.Aliases = slices.Clone(copts.aliases.GetSlice())
copy(n.Aliases, copts.aliases.GetSlice())
} }
// For a user-defined network, "--link" is an endpoint option, it creates an alias. But, // For a user-defined network, "--link" is an endpoint option, it creates an alias. But,
// for the default bridge it defines a legacy-link. // for the default bridge it defines a legacy-link.
if container.NetworkMode(n.Target).IsUserDefined() && copts.links.Len() > 0 { if container.NetworkMode(n.Target).IsUserDefined() && copts.links.Len() > 0 {
n.Links = make([]string, copts.links.Len()) n.Links = slices.Clone(copts.links.GetSlice())
copy(n.Links, copts.links.GetSlice())
} }
if copts.ipv4Address != nil { if copts.ipv4Address != nil {
if ipv4, ok := netip.AddrFromSlice(copts.ipv4Address.To4()); ok { if ipv4, ok := netip.AddrFromSlice(copts.ipv4Address.To4()); ok {
@@ -890,7 +854,6 @@ func parseNetworkAttachmentOpt(ep opts.NetworkAttachmentOpts) (*network.Endpoint
} }
epConfig.Aliases = append(epConfig.Aliases, ep.Aliases...) epConfig.Aliases = append(epConfig.Aliases, ep.Aliases...)
if len(ep.DriverOpts) > 0 { if len(ep.DriverOpts) > 0 {
epConfig.DriverOpts = make(map[string]string)
epConfig.DriverOpts = ep.DriverOpts epConfig.DriverOpts = ep.DriverOpts
} }
if len(ep.Links) > 0 { if len(ep.Links) > 0 {
@@ -934,11 +897,10 @@ func convertToStandardNotation(ports []string) ([]string, error) {
} }
func parseLoggingOpts(loggingDriver string, loggingOpts []string) (map[string]string, error) { func parseLoggingOpts(loggingDriver string, loggingOpts []string) (map[string]string, error) {
loggingOptsMap := opts.ConvertKVStringsToMap(loggingOpts)
if loggingDriver == "none" && len(loggingOpts) > 0 { if loggingDriver == "none" && len(loggingOpts) > 0 {
return map[string]string{}, fmt.Errorf("invalid logging opts for driver %s", loggingDriver) return map[string]string{}, fmt.Errorf("invalid logging opts for driver %s", loggingDriver)
} }
return loggingOptsMap, nil return opts.ConvertKVStringsToMap(loggingOpts), nil
} }
// takes a local seccomp daemon, reads the file contents for sending to the daemon // takes a local seccomp daemon, reads the file contents for sending to the daemon
+149 -292
View File
@@ -2,7 +2,7 @@
// Copyright 2022 The nektos/act Authors. All rights reserved. // Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts_test.go with: // This file is based on https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts_test.go with:
// * appended with license information // * appended with license information
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter // * added tests for the locally changed parseDevice, validateDevice and invalidParameter
// //
@@ -69,7 +69,6 @@ func parseRun(args []string) (*container.Config, *container.HostConfig, *network
if err := flags.Parse(args); err != nil { if err := flags.Parse(args); err != nil {
return nil, nil, nil, err return nil, nil, nil, err
} }
// TODO(dnephin): fix tests to accept ContainerConfig; see https://github.com/moby/moby/pull/31621
containerCfg, err := parse(flags, copts, runtime.GOOS) containerCfg, err := parse(flags, copts, runtime.GOOS)
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, nil, err
@@ -81,13 +80,12 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
flags := pflag.NewFlagSet("run", pflag.ContinueOnError) flags := pflag.NewFlagSet("run", pflag.ContinueOnError)
flags.SetOutput(io.Discard) flags.SetOutput(io.Discard)
flags.Usage = nil flags.Usage = nil
copts := addFlags(flags) return flags, addFlags(flags)
return flags, copts
} }
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) { func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
t.Helper() t.Helper()
config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash")) config, hostConfig, nwConfig, err := parseRun(strings.Split(args, " "))
require.NoError(t, err) require.NoError(t, err)
return config, hostConfig, nwConfig return config, hostConfig, nwConfig
} }
@@ -179,53 +177,21 @@ func TestParseRunUmask(t *testing.T) {
} }
func TestParseRunAttach(t *testing.T) { func TestParseRunAttach(t *testing.T) {
tests := []struct { for _, tc := range []struct {
input string input string
expected container.Config stdin, stdout, stderr bool
}{ }{
{ {"", false, true, true},
input: "", {"-i", true, true, true},
expected: container.Config{ {"-a stdin", true, false, false},
AttachStdout: true, {"-a stdin -a stdout", true, true, false},
AttachStderr: true, {"-a stdin -a stdout -a stderr", true, true, true},
}, } {
},
{
input: "-i",
expected: container.Config{
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
},
},
{
input: "-a stdin",
expected: container.Config{
AttachStdin: true,
},
},
{
input: "-a stdin -a stdout",
expected: container.Config{
AttachStdin: true,
AttachStdout: true,
},
},
{
input: "-a stdin -a stdout -a stderr",
expected: container.Config{
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
},
},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) { t.Run(tc.input, func(t *testing.T) {
config, _, _ := mustParse(t, tc.input) config, _, _ := mustParse(t, tc.input)
require.Equal(t, tc.expected.AttachStdin, config.AttachStdin) require.Equal(t, tc.stdin, config.AttachStdin)
require.Equal(t, tc.expected.AttachStdout, config.AttachStdout) require.Equal(t, tc.stdout, config.AttachStdout)
require.Equal(t, tc.expected.AttachStderr, config.AttachStderr) require.Equal(t, tc.stderr, config.AttachStderr)
}) })
} }
} }
@@ -276,104 +242,47 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
} }
} }
func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from docker/cli tests func TestParseWithVolumes(t *testing.T) {
// A single volume for _, tc := range []struct {
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`}) unix, windows []string
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil { binds int
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds) }{
} else if _, exists := config.Volumes[arr[0]]; !exists { {[]string{`/tmp`}, []string{`c:\tmp`}, 0},
t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes) {[]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`}, 0},
} {[]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`}, 1},
{[]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`}, 2},
// Two volumes {[]string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`}, []string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`}, 2},
arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`}) {[]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, nil, 2},
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil { {[]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, nil, 2},
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds) {[]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`}, 1},
} else if _, exists := config.Volumes[arr[0]]; !exists { {nil, []string{os.Getenv("SystemDrive") + `\:d:`}, 1},
t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes) } {
} else if _, exists := config.Volumes[arr[1]]; !exists { volumes := tc.unix
t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[1], config.Volumes) if runtime.GOOS == "windows" {
} volumes = tc.windows
// A single bind mount
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`})
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] {
t.Fatalf("Error parsing volume flags, %q should mount-bind the path before the colon into the path after the colon. Received %v %v", arr[0], hostConfig.Binds, config.Volumes)
}
// Two bind mounts.
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`})
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
}
// Two bind mounts, first read-only, second read-write.
// TODO Windows: The Windows version uses read-write as that's the only mode it supports. Can change this post TP4
arr, tryit = setupPlatformVolume(
[]string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`},
[]string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`})
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
}
// Similar to previous test but with alternate modes which are only supported by Linux
if runtime.GOOS != "windows" {
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, []string{})
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
} }
if len(volumes) == 0 {
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{}) continue
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
} }
t.Run(strings.Join(volumes, ","), func(t *testing.T) {
var args []string
for _, volume := range volumes {
args = append(args, "-v", volume)
}
config, hostConfig, _, err := parseRun(args)
require.NoError(t, err)
if tc.binds == 0 {
assert.Nil(t, hostConfig.Binds)
} else {
assert.ElementsMatch(t, volumes[:tc.binds], hostConfig.Binds)
}
expectedVolumes := map[string]struct{}{}
for _, volume := range volumes[tc.binds:] {
expectedVolumes[volume] = struct{}{}
}
assert.Equal(t, expectedVolumes, config.Volumes)
})
} }
// One bind mount and one volume
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`})
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] {
t.Fatalf("Error parsing volume flags, %s and %s should only one and only one bind mount %s. Received %s", arr[0], arr[1], arr[0], hostConfig.Binds)
} else if _, exists := config.Volumes[arr[1]]; !exists {
t.Fatalf("Error parsing volume flags %s and %s. %s is missing from volumes. Received %v", arr[0], arr[1], arr[1], config.Volumes)
}
// Root to non-c: drive letter (Windows specific)
if runtime.GOOS == "windows" {
arr, tryit = setupPlatformVolume([]string{}, []string{os.Getenv("SystemDrive") + `\:d:`})
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 {
t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0])
}
}
}
// setupPlatformVolume takes two arrays of volume specs - a Unix style
// spec and a Windows style spec. Depending on the platform being unit tested,
// it returns one of them, along with a volume string that would be passed
// on the docker CLI (e.g. -v /bar -v /foo).
func setupPlatformVolume(u, w []string) ([]string, string) {
var a []string
if runtime.GOOS == "windows" {
a = w
} else {
a = u
}
s := ""
for _, v := range a {
s = s + "-v " + v + " "
}
return a, s
}
// check if (a == c && b == d) || (a == d && b == c)
// because maps are randomized
func compareRandomizedStrings(a, b, c, d string) error {
if a == c && b == d {
return nil
}
if a == d && b == c {
return nil
}
return errors.New("strings don't match")
} }
// Simple parse with MacAddress validation // Simple parse with MacAddress validation
@@ -414,46 +323,29 @@ func TestParseWithMemorySwap(t *testing.T) {
} }
func TestParseHostname(t *testing.T) { func TestParseHostname(t *testing.T) {
validHostnames := map[string]string{ for _, tc := range []struct {
"hostname": "hostname", input, hostname, domainname string
"host-name": "host-name", }{
"hostname123": "hostname123", {"--hostname=hostname", "hostname", ""},
"123hostname": "123hostname", {"--hostname=host-name", "host-name", ""},
"hostname-of-63-bytes-long-should-be-valid-and-without-any-error": "hostname-of-63-bytes-long-should-be-valid-and-without-any-error", {"--hostname=hostname123", "hostname123", ""},
} {"--hostname=123hostname", "123hostname", ""},
hostnameWithDomain := "--hostname=hostname.domainname" {"--hostname=hostname-of-63-bytes-long-should-be-valid-and-without-any-error", "hostname-of-63-bytes-long-should-be-valid-and-without-any-error", ""},
hostnameWithDomainTld := "--hostname=hostname.domainname.tld" {"--hostname=hostname.domainname", "hostname.domainname", ""},
for hostname, expectedHostname := range validHostnames { {"--hostname=hostname.domainname.tld", "hostname.domainname.tld", ""},
if config, _, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname { {"--domainname=domainname", "", "domainname"},
t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname) {"--domainname=domain-name", "", "domain-name"},
} {"--domainname=domainname123", "", "domainname123"},
} {"--domainname=123domainname", "", "123domainname"},
if config, _, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" { {"--domainname=domainname-63-bytes-long-should-be-valid-and-without-any-errors", "", "domainname-63-bytes-long-should-be-valid-and-without-any-errors"},
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname) {"--hostname=some.prefix --domainname=domainname", "some.prefix", "domainname"},
} {"--hostname=another-prefix --domainname=domainname.tld", "another-prefix", "domainname.tld"},
if config, _, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" { } {
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname.tld, got %q", config.Hostname) t.Run(tc.input, func(t *testing.T) {
} config, _, _ := mustParse(t, tc.input)
} require.Equal(t, tc.hostname, config.Hostname)
require.Equal(t, tc.domainname, config.Domainname)
func TestParseHostnameDomainname(t *testing.T) { })
validDomainnames := map[string]string{
"domainname": "domainname",
"domain-name": "domain-name",
"domainname123": "domainname123",
"123domainname": "123domainname",
"domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors",
}
for domainname, expectedDomainname := range validDomainnames {
if config, _, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname {
t.Fatalf("Expected the config to have 'domainname' as %q, got %q", expectedDomainname, config.Domainname)
}
}
if config, _, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" {
t.Fatalf("Expected the config to have 'hostname' as 'some.prefix' and 'domainname' as 'domainname', got %q and %q", config.Hostname, config.Domainname)
}
if config, _, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" {
t.Fatalf("Expected the config to have 'hostname' as 'another-prefix' and 'domainname' as 'domainname.tld', got %q and %q", config.Hostname, config.Domainname)
} }
} }
@@ -497,9 +389,15 @@ func TestParseWithExpose(t *testing.T) {
}) })
t.Run("merge with published", func(t *testing.T) { t.Run("merge with published", func(t *testing.T) {
// Merge with actual published port config, hostConfig, _, err := parseRun([]string{"--publish=80", "--publish=127.0.0.1:8080:80", "--publish=[::1]:8081:80", "--expose=80-81/tcp"})
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, networktypes.PortMap{
networktypes.MustParsePort("80/tcp"): {
{},
{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "8080"},
{HostIP: netip.MustParseAddr("::1"), HostPort: "8081"},
},
}, hostConfig.PortBindings)
assert.Len(t, config.ExposedPorts, 2) assert.Len(t, config.ExposedPorts, 2)
ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")} ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")}
for _, port := range ports { for _, port := range ports {
@@ -984,41 +882,29 @@ func TestParseRestartPolicyAutoRemove(t *testing.T) {
} }
func TestParseHealth(t *testing.T) { func TestParseHealth(t *testing.T) {
checkOk := func(args ...string) *container.HealthConfig { for _, tc := range []struct {
config, _, _, err := parseRun(args) args []string
if err != nil { expected *container.HealthConfig
t.Fatalf("%#v: %v", args, err) wantErr string
} }{
return config.Healthcheck {[]string{"--no-healthcheck", "img", "cmd"}, &container.HealthConfig{Test: []string{"NONE"}}, ""},
} {[]string{"--health-cmd=/check.sh -q", "img", "cmd"}, &container.HealthConfig{Test: []string{"CMD-SHELL", "/check.sh -q"}}, ""},
checkError := func(expected string, args ...string) { {[]string{"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd"}, nil, "--no-healthcheck conflicts with --health-* options"},
config, _, _, err := parseRun(args) {
if err == nil { []string{"--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd"},
t.Fatalf("Expected error, but got %#v", config) &container.HealthConfig{Timeout: 2 * time.Second, Retries: 3, Interval: 4500 * time.Millisecond, StartPeriod: 5 * time.Second, StartInterval: time.Second},
} "",
if err.Error() != expected { },
t.Fatalf("Expected %#v, got %#v", expected, err) } {
} t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
} config, _, _, err := parseRun(tc.args)
health := checkOk("--no-healthcheck", "img", "cmd") if tc.wantErr != "" {
if health == nil || len(health.Test) != 1 || health.Test[0] != "NONE" { require.EqualError(t, err, tc.wantErr)
t.Fatalf("--no-healthcheck failed: %#v", health) } else {
} require.NoError(t, err)
require.Equal(t, tc.expected, config.Healthcheck)
health = checkOk("--health-cmd=/check.sh -q", "img", "cmd") }
if len(health.Test) != 2 || health.Test[0] != "CMD-SHELL" || health.Test[1] != "/check.sh -q" { })
t.Fatalf("--health-cmd: got %#v", health.Test)
}
if health.Timeout != 0 {
t.Fatalf("--health-cmd: timeout = %s", health.Timeout)
}
checkError("--no-healthcheck conflicts with --health-* options",
"--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd")
health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd")
if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second || health.StartInterval != 1*time.Second {
t.Fatalf("--health-*: got %#v", health)
} }
} }
@@ -1037,82 +923,53 @@ func TestParseLoggingOpts(t *testing.T) {
} }
} }
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests func TestParseFileVariables(t *testing.T) {
expErr := "--env-file: open nonexistent: no such file or directory" for _, tc := range []struct {
if runtime.GOOS == "windows" { flag, file, key string
expErr = "--env-file: open nonexistent: The system cannot find the file specified." }{
} {"env", "valid.env", "ENV"},
// env ko {"label", "valid.label", "LABEL"},
if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr { } {
t.Fatalf("Expected an error with message '%s', got %v", expErr, err) t.Run(tc.flag, func(t *testing.T) {
} expectedError := "no such file or directory"
// env ok if runtime.GOOS == "windows" {
config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"}) expectedError = "The system cannot find the file specified."
if err != nil { }
t.Fatal(err) _, _, _, err := parseRun([]string{"--" + tc.flag + "-file=nonexistent"})
} require.EqualError(t, err, "--"+tc.flag+"-file: open nonexistent: "+expectedError)
if len(config.Env) != 1 || config.Env[0] != "ENV1=value1" { for _, extra := range []bool{false, true} {
t.Fatalf("Expected a config with [ENV1=value1], got %v", config.Env) args := []string{"--" + tc.flag + "-file=testdata/" + tc.file}
} expected := []string{tc.key + "1=value1"}
config, _, _, err = parseRun([]string{"--env-file=testdata/valid.env", "--env=ENV2=value2", "img", "cmd"}) if extra {
if err != nil { args = append(args, "--"+tc.flag+"="+tc.key+"2=value2")
t.Fatal(err) expected = append(expected, tc.key+"2=value2")
} }
if len(config.Env) != 2 || config.Env[0] != "ENV1=value1" || config.Env[1] != "ENV2=value2" { config, _, _, err := parseRun(args)
t.Fatalf("Expected a config with [ENV1=value1 ENV2=value2], got %v", config.Env) require.NoError(t, err)
if tc.flag == "env" {
assert.Equal(t, expected, config.Env)
} else {
assert.Len(t, config.Labels, len(expected))
for _, label := range expected {
key, value, _ := strings.Cut(label, "=")
assert.Equal(t, value, config.Labels[key])
}
}
}
})
} }
} }
func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) { func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
// UTF8 with BOM config, _, _, err := parseRun([]string{"--env-file=testdata/utf8.env"})
config, _, _, err := parseRun([]string{"--env-file=testdata/utf8.env", "img", "cmd"}) require.NoError(t, err)
if err != nil { assert.Equal(t, []string{"FOO=BAR", "HELLO=您好", "BAR=FOO"}, config.Env)
t.Fatal(err)
}
env := []string{"FOO=BAR", "HELLO=" + string([]byte{0xe6, 0x82, 0xa8, 0xe5, 0xa5, 0xbd}), "BAR=FOO"}
if len(config.Env) != len(env) {
t.Fatalf("Expected a config with %d env variables, got %v: %v", len(env), len(config.Env), config.Env)
}
for i, v := range env {
if config.Env[i] != v {
t.Fatalf("Expected a config with [%s], got %v", v, []byte(config.Env[i]))
}
}
// UTF16 with BOM for _, file := range []string{"utf16.env", "utf16be.env"} {
e := "invalid utf8 bytes at line" t.Run(file, func(t *testing.T) {
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) { _, _, _, err := parseRun([]string{"--env-file=testdata/" + file})
t.Fatalf("Expected an error with message '%s', got %v", e, err) require.ErrorContains(t, err, "invalid utf8 bytes at line")
} })
// UTF16BE with BOM
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16be.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
t.Fatalf("Expected an error with message '%s', got %v", e, err)
}
}
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
expErr := "--label-file: open nonexistent: no such file or directory"
if runtime.GOOS == "windows" {
expErr = "--label-file: open nonexistent: The system cannot find the file specified."
}
// label ko
if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != expErr {
t.Fatalf("Expected an error with message '%s', got %v", expErr, err)
}
// label ok
config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"})
if err != nil {
t.Fatal(err)
}
if len(config.Labels) != 1 || config.Labels["LABEL1"] != "value1" {
t.Fatalf("Expected a config with [LABEL1:value1], got %v", config.Labels)
}
config, _, _, err = parseRun([]string{"--label-file=testdata/valid.label", "--label=LABEL2=value2", "img", "cmd"})
if err != nil {
t.Fatal(err)
}
if len(config.Labels) != 2 || config.Labels["LABEL1"] != "value1" || config.Labels["LABEL2"] != "value2" {
t.Fatalf("Expected a config with [LABEL1:value1 LABEL2:value2], got %v", config.Labels)
} }
} }
+4 -8
View File
@@ -58,18 +58,14 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
} }
defer cli.Close() defer cli.Close()
if _, err := cli.ImageInspect(ctx, imageName); cerrdefs.IsNotFound(err) { if _, err = cli.ImageRemove(ctx, imageName, client.ImageRemoveOptions{
Force: force,
PruneChildren: pruneChildren,
}); cerrdefs.IsNotFound(err) {
return false, nil return false, nil
} else if err != nil { } else if err != nil {
return false, err return false, err
} }
if _, err = cli.ImageRemove(ctx, imageName, client.ImageRemoveOptions{
Force: force,
PruneChildren: pruneChildren,
}); err != nil {
return false, err
}
return true, nil return true, nil
} }
+32 -23
View File
@@ -5,14 +5,17 @@
package container package container
import ( import (
"bytes"
"context"
"fmt" "fmt"
"os"
"os/exec"
"strings" "strings"
"testing" "testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/act/common/logging"
mobyclient "github.com/moby/moby/client"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -21,49 +24,55 @@ func init() {
logging.SetLevel(logging.LevelDebug) logging.SetLevel(logging.LevelDebug)
} }
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no func loadScratchImage(t *testing.T, platform string) string {
// network or emulation since there is nothing to run) and returns its tag, removing it after
// the test.
func buildScratchImage(t *testing.T, platform string) string {
t.Helper() t.Helper()
tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/")) tag := fmt.Sprintf("act-test-exists-%s-%d:latest", strings.TrimPrefix(platform, "linux/"), time.Now().UnixNano())
cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-") var archive bytes.Buffer
cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n") require.NoError(t, writeFilesTar(t.Context(), &archive, 0, 0,
// Force BuildKit: it records the requested architecture in the image config for a &FileEntry{Name: "config.json", Mode: 0o600, Body: fmt.Sprintf(`{"architecture":%q,"os":"linux","rootfs":{"type":"layers","diff_ids":[]}}`, strings.TrimPrefix(platform, "linux/"))},
// FROM-scratch build, whereas the classic builder ignores --platform and tags it with the &FileEntry{Name: "manifest.json", Mode: 0o600, Body: fmt.Sprintf(`[{"Config":"config.json","RepoTags":[%q],"Layers":[]}]`, tag)},
// host arch, which would break the per-platform existence assertions below. ))
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1") cli, err := GetDockerClient(t.Context())
out, err := cmd.CombinedOutput() require.NoError(t, err)
require.NoError(t, err, string(out)) t.Cleanup(func() { _ = cli.Close() })
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() }) response, err := cli.ImageLoad(t.Context(), &archive)
require.NoError(t, err)
defer response.Close()
require.NoError(t, logDockerResponse(common.Logger(t.Context()), response, false))
t.Cleanup(func() {
_, _ = cli.ImageRemove(context.WithoutCancel(t.Context()), tag, mobyclient.ImageRemoveOptions{Force: true})
})
return tag return tag
} }
func TestImageExistsLocally(t *testing.T) { func TestImageExistsLocally(t *testing.T) {
t.Parallel()
requireDocker(t) requireDocker(t)
ctx := t.Context() ctx := t.Context()
// a non-existent image is reported absent
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64") missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, missing) assert.False(t, missing)
// Build tiny images for two architectures locally so per-platform existence can be checked amd64Ref := loadScratchImage(t, "linux/amd64")
// offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network). arm64Ref := loadScratchImage(t, "linux/arm64")
amd64Ref := buildScratchImage(t, "linux/amd64")
arm64Ref := buildScratchImage(t, "linux/arm64")
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64") amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, amd64Exists) assert.True(t, amd64Exists)
// a non-host architecture image is detected for its own architecture
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64") arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, arm64Exists) assert.True(t, arm64Exists)
// a present image is reported absent for a different platform
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64") wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, wrongPlatform) assert.False(t, wrongPlatform)
removed, err := RemoveImage(ctx, amd64Ref, false, false)
require.NoError(t, err)
assert.True(t, removed)
removed, err = RemoveImage(ctx, amd64Ref, false, false)
require.NoError(t, err)
assert.False(t, removed)
} }
+3 -11
View File
@@ -33,18 +33,10 @@ func logDockerResponse(logger *logging.Logger, dockerResponse io.ReadCloser, isE
defer dockerResponse.Close() defer dockerResponse.Close()
scanner := bufio.NewScanner(dockerResponse) scanner := bufio.NewScanner(dockerResponse)
msg := dockerMessage{} scanner.Buffer(nil, 64*1024*1024) // a build echoes long instructions in one record
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
var msg dockerMessage
msg.ID = ""
msg.Stream = ""
msg.Error = ""
msg.ErrorDetail.Message = ""
msg.Status = ""
msg.Progress = ""
if err := json.Unmarshal(line, &msg); err != nil { if err := json.Unmarshal(line, &msg); err != nil {
writeLog(logger, false, "Unable to unmarshal line [%s] ==> %v", string(line), err) writeLog(logger, false, "Unable to unmarshal line [%s] ==> %v", string(line), err)
continue continue
@@ -74,7 +66,7 @@ func logDockerResponse(logger *logging.Logger, dockerResponse io.ReadCloser, isE
} }
} }
return nil return scanner.Err()
} }
func writeLog(logger *logging.Logger, isError bool, format string, args ...any) { func writeLog(logger *logging.Logger, isError bool, format string, args ...any) {
+8 -8
View File
@@ -85,14 +85,14 @@ networks:
} }
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor { func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
return func(ctx context.Context) error { return common.Executor(func(ctx context.Context) error {
cli, err := GetDockerClient(ctx) cli, err := GetDockerClient(ctx)
if err != nil { if err != nil {
return err return err
} }
defer cli.Close() defer cli.Close()
return createDockerNetwork(ctx, cli, name, opts) return createDockerNetwork(ctx, cli, name, opts)
} }).IfNot(common.Dryrun)
} }
func createDockerNetwork(ctx context.Context, cli client.APIClient, name string, opts NewDockerNetworkCreateExecutorInput) error { func createDockerNetwork(ctx context.Context, cli client.APIClient, name string, opts NewDockerNetworkCreateExecutorInput) error {
@@ -157,7 +157,7 @@ func isAddressPoolExhausted(err error) bool {
} }
func NewDockerNetworkConnectExecutor(networkName, container string) common.Executor { func NewDockerNetworkConnectExecutor(networkName, container string) common.Executor {
return func(ctx context.Context) error { return common.Executor(func(ctx context.Context) error {
cli, err := GetDockerClient(ctx) cli, err := GetDockerClient(ctx)
if err != nil { if err != nil {
return err return err
@@ -167,7 +167,7 @@ func NewDockerNetworkConnectExecutor(networkName, container string) common.Execu
common.Logger(ctx).Warnf("cannot connect %s to network %s, so jobs on it cannot reach the cache: %v", container, networkName, err) common.Logger(ctx).Warnf("cannot connect %s to network %s, so jobs on it cannot reach the cache: %v", container, networkName, err)
} }
return nil return nil
} }).IfNot(common.Dryrun)
} }
func connectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error { func connectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
@@ -182,14 +182,14 @@ func connectNetwork(ctx context.Context, cli client.APIClient, networkName, cont
} }
func NewDockerNetworkDisconnectExecutor(networkName, container string) common.Executor { func NewDockerNetworkDisconnectExecutor(networkName, container string) common.Executor {
return func(ctx context.Context) error { return common.Executor(func(ctx context.Context) error {
cli, err := GetDockerClient(ctx) cli, err := GetDockerClient(ctx)
if err != nil { if err != nil {
return err return err
} }
defer cli.Close() defer cli.Close()
return disconnectNetwork(ctx, cli, networkName, container) return disconnectNetwork(ctx, cli, networkName, container)
} }).IfNot(common.Dryrun)
} }
func disconnectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error { func disconnectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
@@ -201,14 +201,14 @@ func disconnectNetwork(ctx context.Context, cli client.APIClient, networkName, c
} }
func NewDockerNetworkRemoveExecutor(name string) common.Executor { func NewDockerNetworkRemoveExecutor(name string) common.Executor {
return func(ctx context.Context) error { return common.Executor(func(ctx context.Context) error {
cli, err := GetDockerClient(ctx) cli, err := GetDockerClient(ctx)
if err != nil { if err != nil {
return err return err
} }
defer cli.Close() defer cli.Close()
return removeNetwork(ctx, cli, name) return removeNetwork(ctx, cli, name)
} }).IfNot(common.Dryrun)
} }
func removeNetwork(ctx context.Context, cli client.APIClient, name string) error { func removeNetwork(ctx context.Context, cli client.APIClient, name string) error {
+12 -26
View File
@@ -6,6 +6,7 @@ package container
import ( import (
"testing" "testing"
specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -17,32 +18,17 @@ func TestParsePlatform(t *testing.T) {
assert.Nil(t, got) assert.Nil(t, got)
}) })
t.Run("os/arch", func(t *testing.T) { for input, want := range map[string]specs.Platform{
got, err := parsePlatform("linux/amd64") "linux/amd64": {OS: "linux", Architecture: "amd64"},
require.NoError(t, err) "linux/arm/v7": {OS: "linux", Architecture: "arm", Variant: "v7"},
require.NotNil(t, got) "Linux/AMD64/V8": {OS: "linux", Architecture: "amd64", Variant: "v8"},
assert.Equal(t, "linux", got.OS) } {
assert.Equal(t, "amd64", got.Architecture) t.Run(input, func(t *testing.T) {
assert.Empty(t, got.Variant) got, err := parsePlatform(input)
}) require.NoError(t, err)
assert.Equal(t, &want, got)
t.Run("os/arch/variant", func(t *testing.T) { })
got, err := parsePlatform("linux/arm/v7") }
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "linux", got.OS)
assert.Equal(t, "arm", got.Architecture)
assert.Equal(t, "v7", got.Variant)
})
t.Run("input is lowercased", func(t *testing.T) {
got, err := parsePlatform("Linux/AMD64/V8")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "linux", got.OS)
assert.Equal(t, "amd64", got.Architecture)
assert.Equal(t, "v8", got.Variant)
})
for _, bad := range []string{ for _, bad := range []string{
"amd64", "amd64",
+72 -15
View File
@@ -48,6 +48,20 @@ var (
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`) rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
) )
var dockerProxyDirectories, dockerProxyProbes sync.Map
type dockerProxyDirectory struct {
dir, daemonDir string
socket os.FileInfo
}
type dockerProxyProbe struct {
directory dockerProxyDirectory
done chan struct{}
seen bool
err error
}
func NewDockerProxy(ctx context.Context, job string) *DockerProxy { func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
if host := os.Getenv("DOCKER_HOST"); runtime.GOOS != "linux" || host != "" && !strings.HasPrefix(host, "unix://") { if host := os.Getenv("DOCKER_HOST"); runtime.GOOS != "linux" || host != "" && !strings.HasPrefix(host, "unix://") {
return nil return nil
@@ -63,16 +77,17 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
if !ok { if !ok {
return nil return nil
} }
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 { socket, err := os.Stat(daemonSocket)
if err != nil || socket.Mode()&os.ModeSocket == 0 {
return nil return nil
} }
dir, daemonDir := runnerContainerWorkdir(probeCtx, cli) workdir, _ := os.Getwd()
seen := daemonDir != "" key := [3]string{cli.DaemonHost(), workdir, os.TempDir()}
cached, _ := dockerProxyDirectories.Load(key)
directory, seen := cached.(dockerProxyDirectory)
seen = seen && os.SameFile(directory.socket, socket) // daemon replacement invalidates cached paths
if !seen { if !seen {
if dir, err = filepath.Abs(os.TempDir()); err == nil { directory, seen, err = probeDockerProxyDirectory(probeCtx, cli, key, socket)
daemonDir = dir
seen, err = daemonSeesDir(probeCtx, cli, dir, daemonDir)
}
} }
if err != nil { if err != nil {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err) common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
@@ -85,15 +100,51 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
if ctx.Err() != nil { if ctx.Err() != nil {
return nil return nil
} }
proxy, err := StartDockerProxy(daemonSocket, dir, job) proxy, err := StartDockerProxy(daemonSocket, directory.dir, job)
if err != nil { if err != nil {
dockerProxyDirectories.Delete(key)
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err) common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
return nil return nil
} }
proxy.Socket = daemonDir + strings.TrimPrefix(proxy.Socket, dir) dockerProxyDirectories.Store(key, directory)
proxy.Socket = directory.daemonDir + strings.TrimPrefix(proxy.Socket, directory.dir)
return proxy return proxy
} }
func probeDockerProxyDirectory(ctx context.Context, cli client.APIClient, key [3]string, socket os.FileInfo) (dockerProxyDirectory, bool, error) {
for {
probe := &dockerProxyProbe{directory: dockerProxyDirectory{socket: socket}, done: make(chan struct{})}
actual, loaded := dockerProxyProbes.LoadOrStore(key, probe)
if loaded {
probe, _ = actual.(*dockerProxyProbe)
if !os.SameFile(probe.directory.socket, socket) {
dockerProxyProbes.CompareAndDelete(key, probe)
continue
}
select {
case <-ctx.Done():
return dockerProxyDirectory{}, false, ctx.Err()
case <-probe.done:
}
if ctx.Err() == nil && (errors.Is(probe.err, context.Canceled) || errors.Is(probe.err, context.DeadlineExceeded)) {
continue
}
} else {
probe.directory.dir, probe.directory.daemonDir = runnerContainerWorkdir(ctx, cli)
probe.seen = probe.directory.daemonDir != ""
if !probe.seen {
if probe.directory.dir, probe.err = filepath.Abs(key[2]); probe.err == nil {
probe.directory.daemonDir = probe.directory.dir
probe.seen, probe.err = daemonSeesDir(ctx, cli, probe.directory.dir, probe.directory.daemonDir)
}
}
dockerProxyProbes.CompareAndDelete(key, probe)
close(probe.done)
}
return probe.directory, probe.seen, probe.err
}
}
// runnerContainerWorkdir looks the runner's container up by hostname to find the daemon's path to its working directory. // runnerContainerWorkdir looks the runner's container up by hostname to find the daemon's path to its working directory.
func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir, daemonDir string) { func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir, daemonDir string) {
workdir, err := os.Getwd() workdir, err := os.Getwd()
@@ -144,12 +195,18 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
if len(images.Items) == 0 { if len(images.Items) == 0 {
return false, errors.New("no image available for the docker proxy probe") return false, errors.New("no image available for the docker proxy probe")
} }
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{ var created client.ContainerCreateResult
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}}, for _, image := range images.Items {
HostConfig: &container.HostConfig{Mounts: []mount.Mount{ created, err = cli.ContainerCreate(ctx, client.ContainerCreateOptions{
{Type: mount.TypeBind, Source: daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}, // not the marker itself, podman creates a missing bind source where docker rejects it Config: &container.Config{Image: image.ID, Cmd: []string{"true"}},
}}, HostConfig: &container.HostConfig{Mounts: []mount.Mount{
}) {Type: mount.TypeBind, Source: daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}, // not the marker itself, podman creates a missing bind source where docker rejects it
}},
})
if !cerrdefs.IsNotFound(err) {
break
}
}
if cerrdefs.IsInvalidArgument(err) { if cerrdefs.IsInvalidArgument(err) {
return false, nil return false, nil
} }
+187 -1
View File
@@ -20,7 +20,9 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"testing/synctest"
"time" "time"
cerrdefs "github.com/containerd/errdefs" cerrdefs "github.com/containerd/errdefs"
@@ -68,6 +70,7 @@ func TestDockerProxy(t *testing.T) {
bodies := make(chan []byte, 8) bodies := make(chan []byte, 8)
createStarted, releaseCreate := make(chan struct{}), make(chan struct{}) createStarted, releaseCreate := make(chan struct{}), make(chan struct{})
streamsDone := make(chan struct{}, 2) streamsDone := make(chan struct{}, 2)
var probes atomic.Int32
daemonSocket := filepath.Join(shortTempDir(t), "d.sock") daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
listener, err := net.Listen("unix", daemonSocket) listener, err := net.Listen("unix", daemonSocket)
require.NoError(t, err) require.NoError(t, err)
@@ -75,12 +78,28 @@ func TestDockerProxy(t *testing.T) {
switch { switch {
case strings.HasSuffix(r.URL.Path, "/create"): case strings.HasSuffix(r.URL.Path, "/create"):
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
if bytes.Contains(body, []byte(`"Image":"probe-image"`)) {
_, _ = w.Write([]byte(`{"Id":"probe"}`))
return
}
if r.URL.RawQuery == "wait" { if r.URL.RawQuery == "wait" {
close(createStarted) close(createStarted)
<-releaseCreate <-releaseCreate
} }
bodies <- body bodies <- body
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
case strings.HasSuffix(r.URL.Path, "/images/json"):
if probes.Add(1) == 1 {
http.Error(w, "probe unavailable", http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte(`[{"Id":"probe-image"}]`))
case strings.HasSuffix(r.URL.Path, "/json"):
http.NotFound(w, r)
case strings.HasSuffix(r.URL.Path, "/archive"):
w.Header().Set("X-Docker-Container-Path-Stat", "e30=")
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
case r.URL.Path == "/exec/detached/start" || r.URL.Path == "/exec/error/start": case r.URL.Path == "/exec/detached/start" || r.URL.Path == "/exec/error/start":
if r.URL.Path == "/exec/error/start" { if r.URL.Path == "/exec/error/start" {
w.Header().Set("Content-Type", "application/vnd.docker.raw-stream") w.Header().Set("Content-Type", "application/vnd.docker.raw-stream")
@@ -211,6 +230,31 @@ func TestDockerProxy(t *testing.T) {
assert.Equal(t, "OK GET /v1.47/_ping", string(body)) assert.Equal(t, "OK GET /v1.47/_ping", string(body))
}) })
t.Run("discovery caches only successes", func(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("automatic proxy discovery requires Linux")
}
t.Setenv("DOCKER_HOST", "unix://"+daemonSocket)
t.Setenv("TMPDIR", shortTempDir(t))
require.Nil(t, NewDockerProxy(t.Context(), "failed"))
for _, job := range []string{"first", "second"} {
proxy := NewDockerProxy(t.Context(), job)
require.NotNil(t, proxy)
require.NoError(t, proxy.Close(context.Background()))
}
assert.EqualValues(t, 2, probes.Load())
dockerProxyDirectories.Range(func(key, value any) bool {
directory, _ := value.(dockerProxyDirectory)
directory.socket, _ = os.Stat(t.TempDir())
dockerProxyDirectories.Store(key, directory)
return true
})
proxy := NewDockerProxy(t.Context(), "replaced daemon")
require.NotNil(t, proxy)
require.NoError(t, proxy.Close(context.Background()))
assert.EqualValues(t, 3, probes.Load())
})
t.Run("tunnels raw streams through stdin EOF", func(t *testing.T) { t.Run("tunnels raw streams through stdin EOF", func(t *testing.T) {
for _, stream := range []struct{ path, upgrade string }{ for _, stream := range []struct{ path, upgrade string }{
{path: "/v1.47/exec/abc/start"}, {path: "/v1.47/exec/abc/start"},
@@ -328,6 +372,9 @@ func TestDockerProxyWithDaemon(t *testing.T) {
job := "proxy-test-" + strconv.FormatInt(time.Now().UnixNano(), 36) job := "proxy-test-" + strconv.FormatInt(time.Now().UnixNano(), 36)
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job) proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
if os.Geteuid() != 0 && errors.Is(err, os.ErrPermission) {
t.Skipf("non-root runners that cannot take the daemon socket's ownership get it directly: %v", err)
}
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = proxy.Close(context.Background()) }) t.Cleanup(func() { _ = proxy.Close(context.Background()) })
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket)) viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
@@ -440,7 +487,7 @@ func (c *probeClient) ContainerInspect(context.Context, string, mobyclient.Conta
} }
func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) { func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) {
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "probe-image"}}}, nil return mobyclient.ImageListResult{Items: []image.Summary{{ID: "removed-image"}, {ID: "probe-image"}}}, nil
} }
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) { func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
@@ -455,6 +502,139 @@ func (c *probeClient) ContainerStatPath(_ context.Context, _ string, opts mobycl
return mobyclient.ContainerStatPathResult{}, c.stat(opts.Path) return mobyclient.ContainerStatPathResult{}, c.stat(opts.Path)
} }
func TestDockerProxyDirectoryProbes(t *testing.T) {
type result struct {
seen bool
err error
}
for name, createErr := range map[string]error{"unavailable directory": nil, "daemon error": errors.New("probe failed")} {
t.Run(name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
socket, err := os.Stat(t.TempDir())
require.NoError(t, err)
key := [3]string{"daemon", dir, dir}
release := make(chan struct{})
var calls, removals atomic.Int32
visible := false
cli := &probeClient{
create: func(_opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
calls.Add(1)
<-release
return mobyclient.ContainerCreateResult{ID: "probe"}, createErr
},
stat: func(_path string) error {
if visible {
return nil
} else {
return cerrdefs.ErrNotFound
}
},
remove: func(_ctx context.Context, _id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
removals.Add(1)
return mobyclient.ContainerRemoveResult{}, nil
},
}
results := make(chan result, 8)
for range cap(results) {
go func() {
_, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
results <- result{seen, err}
}()
}
synctest.Wait()
assert.EqualValues(t, 1, calls.Load())
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, seen, err := probeDockerProxyDirectory(ctx, cli, key, socket)
require.ErrorIs(t, err, context.Canceled)
assert.False(t, seen)
replacement, err := os.Stat(t.TempDir())
require.NoError(t, err)
replaced := make(chan result, 1)
go func() {
_, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, replacement)
replaced <- result{seen, err}
}()
synctest.Wait()
assert.EqualValues(t, 2, calls.Load())
close(release)
for range cap(results) {
result := <-results
require.ErrorIs(t, result.err, createErr)
assert.False(t, result.seen)
}
replacementResult := <-replaced
require.ErrorIs(t, replacementResult.err, createErr)
assert.False(t, replacementResult.seen)
assert.EqualValues(t, 2, calls.Load())
if createErr == nil {
assert.EqualValues(t, 2, removals.Load())
} else {
assert.Zero(t, removals.Load())
}
createErr, visible = nil, true
directory, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
require.NoError(t, err)
assert.True(t, seen)
assert.Equal(t, dir, directory.dir)
assert.Equal(t, dir, directory.daemonDir)
assert.EqualValues(t, 3, calls.Load())
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(t, entries)
})
})
}
t.Run("cancelled probe does not cancel another caller", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
dir := t.TempDir()
socket, err := os.Stat(t.TempDir())
require.NoError(t, err)
key := [3]string{"daemon", dir, dir}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
release := make(chan struct{})
calls := 0
cli := &probeClient{
create: func(_opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
calls++
if calls == 1 {
<-release
return mobyclient.ContainerCreateResult{}, ctx.Err()
} else {
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
}
},
stat: func(_path string) error { return nil },
remove: func(_ctx context.Context, _id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
return mobyclient.ContainerRemoveResult{}, nil
},
}
leader, follower := make(chan result, 1), make(chan result, 1)
go func() {
_, seen, err := probeDockerProxyDirectory(ctx, cli, key, socket)
leader <- result{seen, err}
}()
synctest.Wait()
go func() {
_, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
follower <- result{seen, err}
}()
synctest.Wait()
assert.Equal(t, 1, calls)
cancel()
close(release)
leaderResult, followerResult := <-leader, <-follower
require.ErrorIs(t, leaderResult.err, context.Canceled)
assert.False(t, leaderResult.seen)
require.NoError(t, followerResult.err)
assert.True(t, followerResult.seen)
assert.Equal(t, 2, calls)
})
})
}
func TestDaemonSeesDir(t *testing.T) { func TestDaemonSeesDir(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
markers := make(map[string]bool) markers := make(map[string]bool)
@@ -474,9 +654,14 @@ func TestDaemonSeesDir(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
removed := false removed := false
var attemptedImages []string
cli := &probeClient{ cli := &probeClient{
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) { create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts) assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts)
attemptedImages = append(attemptedImages, opts.Config.Image)
if opts.Config.Image == "removed-image" {
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrNotFound
}
cancel() cancel()
if _, err := os.Stat(testCase.daemonDir); err != nil { if _, err := os.Stat(testCase.daemonDir); err != nil {
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
@@ -504,6 +689,7 @@ func TestDaemonSeesDir(t *testing.T) {
} }
seen, err := daemonSeesDir(ctx, cli, dir, testCase.daemonDir) seen, err := daemonSeesDir(ctx, cli, dir, testCase.daemonDir)
require.ErrorIs(t, err, testCase.removeErr) require.ErrorIs(t, err, testCase.removeErr)
assert.Equal(t, []string{"removed-image", "probe-image"}, attemptedImages)
assert.Equal(t, testCase.seen, seen) assert.Equal(t, testCase.seen, seen)
assert.Equal(t, testCase.removed, removed) assert.Equal(t, testCase.removed, removed)
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
+1 -1
View File
@@ -114,7 +114,7 @@ func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput)
if err != nil { if err != nil {
return imagePullOptions, err return imagePullOptions, err
} }
if authConfig.Username == "" && authConfig.Password == "" { if authConfig.Username == "" && authConfig.Password == "" && authConfig.IdentityToken == "" && authConfig.RegistryToken == "" {
return imagePullOptions, nil return imagePullOptions, nil
} }
logger.Info("using DockerAuthConfig authentication for docker pull") logger.Info("using DockerAuthConfig authentication for docker pull")
+24
View File
@@ -6,12 +6,17 @@ package container
import ( import (
"io" "io"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"testing/iotest"
"gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest" "gitea.com/gitea/runner/act/common/logging/logtest"
"github.com/moby/moby/api/pkg/authconfig"
"github.com/moby/moby/api/types/registry"
assert "github.com/stretchr/testify/assert" assert "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -64,6 +69,21 @@ func TestGetImagePullOptions(t *testing.T) {
}) })
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config") assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"auths":{"identity.example.com":{"identitytoken":"identity-token"},"registry.example.com":{"registrytoken":"registry-token"}}}`), 0o600))
t.Setenv("DOCKER_CONFIG", dir)
for host, want := range map[string]registry.AuthConfig{
"identity.example.com": {IdentityToken: "identity-token", ServerAddress: "identity.example.com"},
"registry.example.com": {RegistryToken: "registry-token", ServerAddress: "registry.example.com"},
} {
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{Image: host + "/app"})
require.NoError(t, err)
require.NotEmpty(t, options.RegistryAuth, host)
got, err := authconfig.Decode(options.RegistryAuth)
require.NoError(t, err)
assert.Equal(t, want, *got)
}
} }
// A digest-pinned image is immutable, so its local copy is always current. // A digest-pinned image is immutable, so its local copy is always current.
@@ -82,4 +102,8 @@ func TestLogDockerResponseError(t *testing.T) {
require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded") require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded")
require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF") require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF")
require.NoError(t, streamErr(`{"status":"Downloading"}`)) require.NoError(t, streamErr(`{"status":"Downloading"}`))
require.NoError(t, streamErr(`{"stream":"`+strings.Repeat("x", 70000)+`"}`))
require.ErrorIs(t, logDockerResponse(logger, io.NopCloser(io.MultiReader(
strings.NewReader(`{"status":"Downloading"}`+"\n"), iotest.ErrReader(io.ErrUnexpectedEOF),
)), false), io.ErrUnexpectedEOF)
} }
+79 -77
View File
@@ -50,15 +50,12 @@ const drainGracePeriod = 2 * time.Second
// NewContainer creates a reference to a container // NewContainer creates a reference to a container
func NewContainer(input *NewContainerInput) ExecutionsEnvironment { func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
cr := new(containerReference)
cr.input = input
// Resolved up front because the image pull runs before the container is created. // Resolved up front because the image pull runs before the container is created.
cf := createFlagsFromOptions(input.allOptions()) cf := createFlagsFromOptions(input.allOptions())
if cf.platform != "" { if cf.platform != "" {
cr.input.Platform = cf.platform input.Platform = cf.platform
} }
cr.pullPolicy = cf.pull return &containerReference{input: input, pullPolicy: cf.pull}
return cr
} }
// supportsContainerImagePlatform reports whether the Docker server API version // supportsContainerImagePlatform reports whether the Docker server API version
@@ -208,18 +205,30 @@ func (cr *containerReference) DumpLogs(ctx context.Context) error {
if cr.id == "" { if cr.id == "" {
return cr.missingContainerError("read its logs") return cr.missingContainerError("read its logs")
} }
tty, err := cr.containerTTY(ctx)
if err != nil {
return err
}
logs, err := cr.cli.ContainerLogs(ctx, cr.id, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}) logs, err := cr.cli.ContainerLogs(ctx, cr.id, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil { if err != nil {
return err return err
} }
defer logs.Close() defer logs.Close()
return cr.copyOutput(logs) return cr.copyOutput(logs, tty)
}
func (cr *containerReference) containerTTY(ctx context.Context) (bool, error) {
inspect, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
return inspect.Container.Config != nil && inspect.Container.Config.Tty, nil
} }
// copyOutput writes a container stream to the writers the container was created with, // copyOutput writes a container stream to the writers the container was created with,
// demultiplexing it unless the container has a TTY, which sends a single raw stream. // demultiplexing it unless the container has a TTY, which sends a single raw stream.
func (cr *containerReference) copyOutput(stream io.Reader) error { func (cr *containerReference) copyOutput(stream io.Reader, tty bool) error {
outWriter := cr.input.Stdout outWriter := cr.input.Stdout
if outWriter == nil { if outWriter == nil {
outWriter = os.Stdout outWriter = os.Stdout
@@ -230,7 +239,7 @@ func (cr *containerReference) copyOutput(stream io.Reader) error {
} }
var err error var err error
if !cr.input.AllocatePTY || os.Getenv("NORAW") != "" { if !tty || os.Getenv("NORAW") != "" {
_, err = stdcopy.StdCopy(outWriter, errWriter, stream) _, err = stdcopy.StdCopy(outWriter, errWriter, stream)
} else { } else {
_, err = io.Copy(outWriter, stream) _, err = io.Copy(outWriter, stream)
@@ -299,7 +308,7 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
} }
func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
return parseEnvFile(cr, srcPath, env).IfNot(common.Dryrun) return parseEnvFile(cr, srcPath, env, false).IfNot(common.Dryrun)
} }
func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.Executor { func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.Executor {
@@ -394,17 +403,16 @@ func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) {
return cli, nil return cli, nil
} }
func GetHostInfo(ctx context.Context) (info system.Info, err error) { func GetHostInfo(ctx context.Context) (system.Info, error) {
var cli client.APIClient cli, err := GetDockerClient(ctx)
cli, err = GetDockerClient(ctx)
if err != nil { if err != nil {
return info, err return system.Info{}, err
} }
defer cli.Close() defer cli.Close()
result, err := cli.Info(ctx, client.InfoOptions{}) result, err := cli.Info(ctx, client.InfoOptions{})
if err != nil { if err != nil {
return info, err return system.Info{}, err
} }
return result.Info, nil return result.Info, nil
@@ -476,22 +484,23 @@ func (cr *containerReference) find() common.Executor {
} }
cr.id = "" cr.id = ""
} }
containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{ result, err := cr.cli.ContainerInspect(ctx, cr.input.Name, client.ContainerInspectOptions{})
All: true, if err == nil && result.Container.Name == "/"+cr.input.Name {
}) cr.id = result.Container.ID
return nil
} else if cerrdefs.IsNotFound(err) {
return nil
}
containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{All: true}) // inspect also matches ids, and podman fails it with a 500 for a container removed mid-request
if err != nil { if err != nil {
return fmt.Errorf("failed to list containers: %w", err) return fmt.Errorf("failed to list containers: %w", err)
} }
for _, candidate := range containers.Items {
for _, c := range containers.Items { if slices.Contains(candidate.Names, "/"+cr.input.Name) {
for _, name := range c.Names { cr.id = candidate.ID
if name[1:] == cr.input.Name { break
cr.id = c.ID
return nil
}
} }
} }
return nil return nil
} }
} }
@@ -502,7 +511,7 @@ func (cr *containerReference) remove() common.Executor {
if idOrName == "" && cr.input != nil { if idOrName == "" && cr.input != nil {
idOrName = cr.input.Name idOrName = cr.input.Name
} }
if idOrName == "" { if idOrName == "" || cr.cli == nil {
return nil return nil
} }
@@ -558,32 +567,32 @@ func (input *NewContainerInput) allOptions() string {
return strings.TrimSpace(input.RunnerOptions + " " + input.WorkflowOptions) return strings.TrimSpace(input.RunnerOptions + " " + input.WorkflowOptions)
} }
func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig, error) { func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
options := cr.input.allOptions() options := cr.input.allOptions()
if options == "" { if options == "" {
return config, hostConfig, nil return nil
} }
// For Gitea, checked here because the parse below is what would read those files // For Gitea, checked here because the parse below is what would read those files
if err := rejectHostReadingOptions(cr.input.WorkflowOptions); err != nil { if err := rejectHostReadingOptions(cr.input.WorkflowOptions); err != nil {
return nil, nil, err return err
} }
// parse configuration from CLI container.options // parse configuration from CLI container.options
flags, copts, cf, err := parseContainerOptions(options) flags, copts, cf, err := parseContainerOptions(options)
if err != nil { if err != nil {
return nil, nil, err return err
} }
if err := cf.validate(); err != nil { if err := cf.validate(); err != nil {
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err) return fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
} }
if len(copts.netMode.Value()) == 0 { if len(copts.netMode.Value()) == 0 {
if err = copts.netMode.Set(cr.input.NetworkMode); err != nil { if err = copts.netMode.Set(cr.input.NetworkMode); err != nil {
return nil, nil, fmt.Errorf("cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err) return fmt.Errorf("cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err)
} }
} }
@@ -596,7 +605,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
containerConfig, err := parse(flags, copts, runtime.GOOS) containerConfig, err := parse(flags, copts, runtime.GOOS)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err) return 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 // workflow aliases join the runner's own, deduped so a re-create cannot grow the input
for _, endpoint := range containerConfig.NetworkingConfig.EndpointsConfig { for _, endpoint := range containerConfig.NetworkingConfig.EndpointsConfig {
@@ -611,7 +620,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
if !hostConfig.Privileged { if !hostConfig.Privileged {
trusted, err := parseOptionsHostConfig(cr.input.RunnerOptions) trusted, err := parseOptionsHostConfig(cr.input.RunnerOptions)
if err != nil { if err != nil {
return nil, nil, err return err
} }
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig, trusted) sanitizeOptionsHostConfig(logger, containerConfig.HostConfig, trusted)
} }
@@ -623,7 +632,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig) logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
cr.sanitizeConfig(ctx, nil, containerConfig.HostConfig) // a dropped volume must not displace the runner's own cr.sanitizeConfig(ctx, containerConfig.HostConfig) // a dropped volume must not displace the runner's own
overlayVolumes(hostConfig, containerConfig.HostConfig) overlayVolumes(hostConfig, containerConfig.HostConfig)
binds := hostConfig.Binds binds := hostConfig.Binds
mounts := hostConfig.Mounts mounts := hostConfig.Mounts
@@ -644,7 +653,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
hostConfig.NetworkMode = networkMode hostConfig.NetworkMode = networkMode
logger.Debugf("Merged container.HostConfig ==> %+v", hostConfig) logger.Debugf("Merged container.HostConfig ==> %+v", hostConfig)
return config, hostConfig, nil return nil
} }
func (cr *containerReference) create(capAdd, capDrop []string) common.Executor { func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
@@ -710,8 +719,7 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
AutoRemove: input.AutoRemove, AutoRemove: input.AutoRemove,
} }
config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig) if err := cr.mergeContainerConfigs(ctx, config, hostConfig); err != nil {
if err != nil {
return err return err
} }
@@ -720,18 +728,15 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
} }
// For Gitea // For Gitea
config, hostConfig = cr.sanitizeConfig(ctx, config, hostConfig) cr.sanitizeConfig(ctx, hostConfig)
var networkingConfig *network.NetworkingConfig var networkingConfig *network.NetworkingConfig
n := hostConfig.NetworkMode n := hostConfig.NetworkMode
// IsUserDefined and IsHost are broken on windows // IsUserDefined and IsHost are broken on windows
if n.IsUserDefined() && n != "host" && len(input.NetworkAliases) > 0 { if n.IsUserDefined() && n != "host" && len(input.NetworkAliases) > 0 {
endpointConfig := &network.EndpointSettings{
Aliases: input.NetworkAliases,
}
networkingConfig = &network.NetworkingConfig{ networkingConfig = &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{ EndpointsConfig: map[string]*network.EndpointSettings{
input.NetworkMode: endpointConfig, input.NetworkMode: {Aliases: input.NetworkAliases},
}, },
} }
} }
@@ -772,15 +777,11 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
logger.Debugf("Exec command '%s'", cmd) logger.Debugf("Exec command '%s'", cmd)
isTerminal := cr.input.AllocatePTY isTerminal := cr.input.AllocatePTY
var wd string wd := cr.input.WorkingDir
if workdir != "" { if strings.HasPrefix(workdir, "/") {
if strings.HasPrefix(workdir, "/") { wd = workdir
wd = workdir } else if workdir != "" {
} else { wd = fmt.Sprintf("%s/%s", cr.input.WorkingDir, workdir)
wd = fmt.Sprintf("%s/%s", cr.input.WorkingDir, workdir)
}
} else {
wd = cr.input.WorkingDir
} }
logger.Debugf("Working directory '%s'", wd) logger.Debugf("Working directory '%s'", wd)
@@ -805,8 +806,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
} }
defer resp.Close() defer resp.Close()
err = cr.waitForCommand(ctx, resp.HijackedResponse) if err := cr.waitForCommand(ctx, resp.HijackedResponse); err != nil {
if err != nil {
return err return err
} }
@@ -863,7 +863,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
cmdResponse := make(chan error, 1) cmdResponse := make(chan error, 1)
go func() { go func() {
cmdResponse <- cr.copyOutput(resp.Reader) cmdResponse <- cr.copyOutput(resp.Reader, cr.input.AllocatePTY)
}() }()
select { select {
@@ -879,7 +879,10 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
// truncate the tail of the log. The goroutine exits once the hijacked // truncate the tail of the log. The goroutine exits once the hijacked
// stream is closed by resp.Close() in the caller's defer. // stream is closed by resp.Close() in the caller's defer.
select { select {
case <-cmdResponse: case err := <-cmdResponse:
if err != nil {
logger.Debugf("Failed to drain command output after cancellation: %v", err)
}
case <-time.After(drainGracePeriod): case <-time.After(drainGracePeriod):
logger.Warn("Timed out draining command output after cancellation") logger.Warn("Timed out draining command output after cancellation")
} }
@@ -888,11 +891,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
// from executing // from executing
return ctx.Err() return ctx.Err()
case err := <-cmdResponse: case err := <-cmdResponse:
if err != nil { return err
logger.Error(err)
}
return nil
} }
} }
@@ -908,17 +907,14 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
return err return err
} }
logger.Debugf("Writing tarball %s from %s", tarFile.Name(), srcPath) logger.Debugf("Writing tarball %s from %s", tarFile.Name(), srcPath)
defer func(tarFile *os.File) { defer func() {
name := tarFile.Name() if err := tarFile.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
err := tarFile.Close()
if !errors.Is(err, os.ErrClosed) {
logger.Error(err) logger.Error(err)
} }
err = os.Remove(name) if err := os.Remove(tarFile.Name()); err != nil {
if err != nil {
logger.Error(err) logger.Error(err)
} }
}(tarFile) }()
extractionRoot := "/" extractionRoot := "/"
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") { if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
extractionRoot = actPath // keeps tar entries from crossing the image's /var/run symlink extractionRoot = actPath // keeps tar entries from crossing the image's /var/run symlink
@@ -928,8 +924,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
} }
logger.Debugf("Extracting content from '%s' to '%s'", tarFile.Name(), dstPath) logger.Debugf("Extracting content from '%s' to '%s'", tarFile.Name(), dstPath)
_, err = tarFile.Seek(0, 0) if _, err := tarFile.Seek(0, 0); err != nil {
if err != nil {
return fmt.Errorf("failed to seek tar archive: %w", err) return fmt.Errorf("failed to seek tar archive: %w", err)
} }
return cr.upload(ctx, extractionRoot, tarFile) return cr.upload(ctx, extractionRoot, tarFile)
@@ -960,6 +955,10 @@ func (cr *containerReference) upload(ctx context.Context, dstPath string, conten
func (cr *containerReference) attach() common.Executor { func (cr *containerReference) attach() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
tty, err := cr.containerTTY(ctx)
if err != nil {
return err
}
out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{ out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{
Stream: true, Stream: true,
Stdout: true, Stdout: true,
@@ -970,9 +969,12 @@ func (cr *containerReference) attach() common.Executor {
} }
done := make(chan struct{}) done := make(chan struct{})
cr.attachDone = done cr.attachDone = done
stopClose := context.AfterFunc(ctx, out.Close)
go func() { go func() {
defer close(done) defer close(done)
if copyErr := cr.copyOutput(out.Reader); copyErr != nil { defer out.Close()
defer stopClose()
if copyErr := cr.copyOutput(out.Reader, tty); copyErr != nil && ctx.Err() == nil {
common.Logger(ctx).Error(copyErr) common.Logger(ctx).Error(copyErr)
} }
}() }()
@@ -1007,6 +1009,9 @@ func (cr *containerReference) wait() common.Executor {
return fmt.Errorf("failed to wait for container: %w", err) return fmt.Errorf("failed to wait for container: %w", err)
} }
case status := <-waitResult.Result: case status := <-waitResult.Result:
if status.Error != nil {
return fmt.Errorf("failed to wait for container: %s", status.Error.Message)
}
statusCode = status.StatusCode statusCode = status.StatusCode
} }
@@ -1097,9 +1102,7 @@ func parseOptionsHostConfig(options string) (*container.HostConfig, error) {
return containerConfig.HostConfig, nil return containerConfig.HostConfig, nil
} }
// For Gitea func (cr *containerReference) sanitizeConfig(ctx context.Context, hostConfig *container.HostConfig) {
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes) matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
@@ -1148,13 +1151,12 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
} }
} }
hostConfig.Mounts = sanitizedMounts hostConfig.Mounts = sanitizedMounts
return config, hostConfig
} }
func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string { func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string {
cr := &containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}} cr := &containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}
_, hostConfig := cr.sanitizeConfig(ctx, nil, &container.HostConfig{Binds: binds}) hostConfig := &container.HostConfig{Binds: binds}
cr.sanitizeConfig(ctx, hostConfig)
return hostConfig.Binds return hostConfig.Binds
} }
+200 -161
View File
@@ -13,9 +13,13 @@ import (
"errors" "errors"
"io" "io"
"net" "net"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"sync/atomic"
"testing" "testing"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -88,6 +92,16 @@ func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID stri
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1) return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
} }
func (m *mockDockerClient) ContainerLogs(ctx context.Context, containerID string, opts mobyclient.ContainerLogsOptions) (mobyclient.ContainerLogsResult, error) {
args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerLogsResult), args.Error(1)
}
func (m *mockDockerClient) ContainerStart(ctx context.Context, containerID string, opts mobyclient.ContainerStartOptions) (mobyclient.ContainerStartResult, error) {
args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerStartResult), args.Error(1)
}
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult { func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
args := m.Called(ctx, containerID, opts) args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerWaitResult) return args.Get(0).(mobyclient.ContainerWaitResult)
@@ -154,6 +168,7 @@ func (r *interruptReader) Read(_ []byte) (int, error) {
type mockConn struct { type mockConn struct {
net.Conn net.Conn
mock.Mock mock.Mock
closed atomic.Bool
} }
func (m *mockConn) Write(b []byte) (n int, err error) { func (m *mockConn) Write(b []byte) (n int, err error) {
@@ -162,6 +177,7 @@ func (m *mockConn) Write(b []byte) (n int, err error) {
} }
func (m *mockConn) Close() (err error) { func (m *mockConn) Close() (err error) {
m.closed.Store(true)
return nil return nil
} }
@@ -182,11 +198,9 @@ func TestDockerExecAbort(t *testing.T) {
}, nil) }, nil)
cr := &containerReference{ cr := &containerReference{
id: "123", id: "123",
cli: client, cli: client,
input: &NewContainerInput{ input: &NewContainerInput{},
Image: "image",
},
} }
channel := make(chan error) channel := make(chan error)
@@ -209,12 +223,10 @@ func TestDockerExecAbort(t *testing.T) {
func TestDockerExecFailure(t *testing.T) { func TestDockerExecFailure(t *testing.T) {
ctx := t.Context() ctx := t.Context()
conn := &mockConn{}
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil) client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{ client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
Conn: conn, Conn: &mockConn{},
Reader: bufio.NewReader(strings.NewReader("output")), Reader: bufio.NewReader(strings.NewReader("output")),
}, nil) }, nil)
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{ client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
@@ -222,11 +234,9 @@ func TestDockerExecFailure(t *testing.T) {
}, nil) }, nil)
cr := &containerReference{ cr := &containerReference{
id: "123", id: "123",
cli: client, cli: client,
input: &NewContainerInput{ input: &NewContainerInput{},
Image: "image",
},
} }
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx) err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
@@ -235,7 +245,21 @@ func TestDockerExecFailure(t *testing.T) {
assert.Equal(t, ExitCodeError(1), exitErr) assert.Equal(t, ExitCodeError(1), exitErr)
assert.Equal(t, "Process completed with exit code 1.", err.Error()) assert.Equal(t, "Process completed with exit code 1.", err.Error())
conn.AssertExpectations(t) client.AssertExpectations(t)
}
func TestDockerExecStreamFailure(t *testing.T) {
ctx := t.Context()
client := &mockDockerClient{}
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).
Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).
Return(mobyclient.ExecAttachResult{
Conn: &mockConn{},
Reader: bufio.NewReader(bytes.NewReader(stdcopyFrame(stdcopy.Systemerr, "unable to start exec"))),
}, nil)
require.ErrorContains(t, (&containerReference{id: "123", cli: client, input: &NewContainerInput{}}).
exec([]string{"sh"}, nil, "", "")(ctx), "unable to start exec")
client.AssertExpectations(t) client.AssertExpectations(t)
} }
@@ -250,86 +274,89 @@ func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
return b return b
} }
// TestDockerAttachFlushesTrailingLine verifies that wait() blocks until the
// attach() streaming goroutine has drained and flushed the container's output,
// so a final line without a trailing newline is not lost.
func TestDockerAttachFlushesTrailingLine(t *testing.T) { func TestDockerAttachFlushesTrailingLine(t *testing.T) {
ctx := t.Context() for _, testcase := range []struct {
name string
tty bool
}{
{name: "multiplexed"},
{name: "tty from container options", tty: true},
} {
t.Run(testcase.name, func(t *testing.T) {
ctx := t.Context()
output := []byte("line one\nlast line without newline")
if !testcase.tty {
output = stdcopyFrame(stdcopy.Stdout, string(output))
}
var lines []string
logWriter := common.NewLineWriter(func(line string) bool {
lines = append(lines, line)
return true
})
client := &mockDockerClient{}
client.On("ContainerInspect", ctx, "123", mobyclient.ContainerInspectOptions{}).
Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{Config: &container.Config{Tty: testcase.tty}}}, nil)
conn := &mockConn{}
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
Return(mobyclient.ContainerAttachResult{
Conn: conn,
Reader: bufio.NewReader(bytes.NewReader(output)),
}, nil)
client.On("ContainerLogs", ctx, "123", mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}).
Return(io.NopCloser(bytes.NewReader(output)), nil)
statusCh := make(chan container.WaitResponse, 1)
statusCh <- container.WaitResponse{StatusCode: 0}
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
Return(mobyclient.ContainerWaitResult{Result: statusCh})
cr := &containerReference{id: "123", cli: client, input: &NewContainerInput{Stdout: logWriter, Stderr: logWriter}}
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline")) require.NoError(t, cr.attach()(ctx))
require.NoError(t, cr.wait()(ctx))
var lines []string assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
logWriter := common.NewLineWriter(func(s string) bool { assert.True(t, conn.closed.Load())
lines = append(lines, s) require.NoError(t, cr.DumpLogs(ctx))
return true assert.Equal(t, []string{"line one\n", "last line without newline", "line one\n", "last line without newline"}, lines)
}) client.AssertExpectations(t)
client := &mockDockerClient{}
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
Return(mobyclient.ContainerAttachResult{
Conn: &mockConn{},
Reader: bufio.NewReader(framed),
}, nil)
statusCh := make(chan container.WaitResponse, 1)
statusCh <- container.WaitResponse{StatusCode: 0}
errCh := make(chan error, 1)
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
Return(mobyclient.ContainerWaitResult{
Result: (<-chan container.WaitResponse)(statusCh),
Error: (<-chan error)(errCh),
}) })
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
Stdout: logWriter,
Stderr: logWriter,
},
} }
}
require.NoError(t, cr.attach()(ctx)) func TestDockerAttachStreamClosesWithItsContext(t *testing.T) {
require.NoError(t, cr.wait()(ctx)) conn, peer := net.Pipe()
t.Cleanup(func() { _ = conn.Close() })
// wait() must have blocked until the goroutine drained AND flushed; the t.Cleanup(func() { _ = peer.Close() })
// trailing, non-newline-terminated line must therefore be present. Reading client := &mockDockerClient{}
// lines here is race-free because wait() synchronizes on attachDone, which client.On("ContainerInspect", mock.Anything, "123", mobyclient.ContainerInspectOptions{}).
// the goroutine closes after the final append. Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{Config: &container.Config{}}}, nil)
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines) client.On("ContainerAttach", mock.Anything, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
Return(mobyclient.ContainerAttachResult{HijackedResponse: mobyclient.NewHijackedResponse(conn, "")}, nil)
client.On("ContainerStart", mock.Anything, "123", mobyclient.ContainerStartOptions{}).
Return(mobyclient.ContainerStartResult{}, errors.New("container start failed"))
cr := &containerReference{id: "123", cli: client, input: &NewContainerInput{}}
ctx, cancel := context.WithCancel(t.Context())
require.ErrorContains(t, cr.Start(true)(ctx), "container start failed")
cancel()
<-cr.attachDone
client.AssertExpectations(t) client.AssertExpectations(t)
} }
func TestDockerWaitFailure(t *testing.T) { func TestDockerWaitFailure(t *testing.T) {
ctx := t.Context() ctx := t.Context()
statusCh := make(chan container.WaitResponse, 1) statusCh := make(chan container.WaitResponse, 1)
statusCh <- container.WaitResponse{StatusCode: 2} statusCh <- container.WaitResponse{StatusCode: 2}
errCh := make(chan error, 1)
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}). client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
Return(mobyclient.ContainerWaitResult{ Return(mobyclient.ContainerWaitResult{Result: statusCh})
Result: (<-chan container.WaitResponse)(statusCh), cr := &containerReference{id: "123", cli: client}
Error: (<-chan error)(errCh),
})
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
},
}
err := cr.wait()(ctx) err := cr.wait()(ctx)
var exitErr ExitCodeError var exitErr ExitCodeError
require.ErrorAs(t, err, &exitErr) require.ErrorAs(t, err, &exitErr)
assert.Equal(t, ExitCodeError(2), exitErr) assert.Equal(t, ExitCodeError(2), exitErr)
assert.Equal(t, "Process completed with exit code 2.", err.Error()) assert.Equal(t, "Process completed with exit code 2.", err.Error())
statusCh <- container.WaitResponse{Error: &container.WaitExitError{Message: "container wait failed"}}
require.EqualError(t, cr.wait()(ctx), "failed to wait for container: container wait failed")
client.AssertExpectations(t) client.AssertExpectations(t)
} }
@@ -398,62 +425,79 @@ func TestRemoveWithoutIDUsesName(t *testing.T) {
client.AssertExpectations(t) client.AssertExpectations(t)
} }
// find() must drop a stale cached id so later Copy/Exec don't hit the func TestRemovePreservesClientCreationError(t *testing.T) {
// daemon with a torn-down container. t.Setenv("DOCKER_HOST", "://")
require.ErrorContains(t, (&containerReference{input: &NewContainerInput{Name: "job-1"}}).
Remove()(t.Context()), "failed to connect to docker daemon")
}
func TestFindRevalidatesStaleID(t *testing.T) { func TestFindRevalidatesStaleID(t *testing.T) {
ctx := t.Context() ctx := t.Context()
inspectOpts := mobyclient.ContainerInspectOptions{}
notFound := cerrdefs.ErrNotFound.WithMessage("No such container") notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
boom := errors.New("daemon unreachable") boom := errors.New("daemon unreachable")
newCR := func(id string) (*containerReference, *mockDockerClient) { for _, tc := range []struct {
client := &mockDockerClient{} name, cachedID string
return &containerReference{id: id, cli: client, input: &NewContainerInput{Name: "job-1"}}, client cachedErr error
lookup container.InspectResponse
lookupErr error
listed []container.Summary
wantID string
retryList bool
}{
{name: "stale id cleared, name lookup empty", cachedID: "stale", cachedErr: notFound, lookupErr: notFound},
{name: "stale id cleared, name lookup repopulates", cachedID: "stale", cachedErr: notFound, lookup: container.InspectResponse{ID: "fresh", Name: "/job-1"}, wantID: "fresh"},
{name: "id match falls back to listing", lookup: container.InspectResponse{ID: "job-1abc", Name: "/somebody-else"}, listed: []container.Summary{{ID: "fresh", Names: []string{"/job-1"}}}, wantID: "fresh"},
{name: "live id kept", cachedID: "live", wantID: "live"},
{name: "transient inspect error trusts cache", cachedID: "live", cachedErr: boom, wantID: "live"},
{name: "name lookup error falls back to listing", lookupErr: boom, listed: []container.Summary{{ID: "other", Names: []string{"/somebody-else"}}, {ID: "fresh", Names: []string{"/job-1"}}}, wantID: "fresh", retryList: true},
} {
t.Run(tc.name, func(t *testing.T) {
client := &mockDockerClient{}
cr := &containerReference{id: tc.cachedID, cli: client, input: &NewContainerInput{Name: "job-1"}}
if tc.cachedID != "" {
client.On("ContainerInspect", ctx, tc.cachedID, inspectOpts).Return(mobyclient.ContainerInspectResult{}, tc.cachedErr)
}
if tc.cachedID == "" || cerrdefs.IsNotFound(tc.cachedErr) {
client.On("ContainerInspect", ctx, "job-1", inspectOpts).Return(mobyclient.ContainerInspectResult{Container: tc.lookup}, tc.lookupErr)
}
if tc.listed != nil {
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{Items: tc.listed}, nil).Once()
}
require.NoError(t, cr.find()(ctx))
assert.Equal(t, tc.wantID, cr.id)
if tc.retryList {
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, boom)
cr.id = ""
require.ErrorIs(t, cr.find()(ctx), boom)
}
client.AssertExpectations(t)
})
} }
listOpts := mobyclient.ContainerListOptions{All: true} }
inspectOpts := mobyclient.ContainerInspectOptions{}
t.Run("stale id cleared, name lookup empty", func(t *testing.T) { func TestRunnerArchCachedPerRun(t *testing.T) {
cr, client := newCR("stale") var infos atomic.Int32
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound) daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, nil) writer.Header().Set("Content-Type", "application/json")
require.NoError(t, cr.find()(ctx)) writer.Header().Set("API-Version", "1.47")
assert.Empty(t, cr.id) if strings.HasSuffix(request.URL.Path, "/info") {
client.AssertExpectations(t) infos.Add(1)
}) _, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
}
t.Run("stale id cleared, name lookup repopulates", func(t *testing.T) { }))
cr, client := newCR("stale") t.Cleanup(daemon.Close)
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound) t.Setenv("DOCKER_HOST", daemon.URL)
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{Items: []container.Summary{ extensions := &LinuxContainerEnvironmentExtensions{}
{ID: "other", Names: []string{"/somebody-else"}}, run := WithRunnerArch(t.Context())
{ID: "fresh", Names: []string{"/job-1"}}, var lookups sync.WaitGroup
}}, nil) for range 4 {
require.NoError(t, cr.find()(ctx)) lookups.Go(func() { assert.Equal(t, "ARM64", extensions.GetRunnerContext(run)["arch"]) })
assert.Equal(t, "fresh", cr.id) }
client.AssertExpectations(t) lookups.Wait()
}) assert.Equal(t, "ARM64", extensions.GetRunnerContext(t.Context())["arch"])
assert.EqualValues(t, 2, infos.Load())
t.Run("live id kept", func(t *testing.T) {
cr, client := newCR("live")
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, nil)
require.NoError(t, cr.find()(ctx))
assert.Equal(t, "live", cr.id)
client.AssertExpectations(t)
})
t.Run("transient inspect error trusts cache", func(t *testing.T) {
cr, client := newCR("live")
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, boom)
require.NoError(t, cr.find()(ctx))
assert.Equal(t, "live", cr.id)
client.AssertExpectations(t)
})
t.Run("list error propagates", func(t *testing.T) {
cr, client := newCR("")
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, boom)
require.ErrorIs(t, cr.find()(ctx), boom)
client.AssertExpectations(t)
})
} }
// Every daemon entry point fails fast with a clear, container-named // Every daemon entry point fails fast with a clear, container-named
@@ -461,7 +505,8 @@ func TestFindRevalidatesStaleID(t *testing.T) {
func TestRejectsMissingContainer(t *testing.T) { func TestRejectsMissingContainer(t *testing.T) {
ctx := t.Context() ctx := t.Context()
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil) client.On("ContainerInspect", ctx, "job-1", mobyclient.ContainerInspectOptions{}).
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound)
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}} cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
check := func(op string, err error) { check := func(op string, err error) {
t.Helper() t.Helper()
@@ -494,14 +539,13 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
{name: "sibling prefix", directory: true, destination: "/var/run/actor/action", extractionRoot: "/", entry: "var/run/actor/action/x"}, {name: "sibling prefix", directory: true, destination: "/var/run/actor/action", extractionRoot: "/", entry: "var/run/actor/action/x"},
} { } {
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
ctx := t.Context() hook := logtest.NewWithLevel(logging.LevelError)
ctx := common.WithLogger(t.Context(), logging.New(hook))
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}). client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone")) Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}). client.On("ContainerInspect", ctx, "job-1", mobyclient.ContainerInspectOptions{}).
Return(mobyclient.ContainerListResult{Items: []container.Summary{ Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{ID: "fresh", Name: "/job-1"}}, nil)
{ID: "fresh", Names: []string{"/job-1"}},
}}, nil)
client.On("CopyToContainer", ctx, "fresh", mock.Anything). client.On("CopyToContainer", ctx, "fresh", mock.Anything).
Run(func(args mock.Arguments) { Run(func(args mock.Arguments) {
opts := args.Get(2).(mobyclient.CopyToContainerOptions) opts := args.Get(2).(mobyclient.CopyToContainerOptions)
@@ -520,14 +564,12 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx)) require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
} }
assert.Equal(t, "fresh", cr.id) assert.Equal(t, "fresh", cr.id)
assert.Empty(t, hook.AllRecords())
client.AssertExpectations(t) client.AssertExpectations(t)
}) })
} }
} }
// Type assert containerReference implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &containerReference{}
func TestCheckVolumes(t *testing.T) { func TestCheckVolumes(t *testing.T) {
testCases := []struct { testCases := []struct {
desc string desc string
@@ -622,10 +664,11 @@ func TestCheckVolumes(t *testing.T) {
ValidVolumes: tc.validVolumes, ValidVolumes: tc.validVolumes,
}, },
} }
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts}) hostConf := &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts}
cr.sanitizeConfig(ctx, hostConf)
assert.Equal(t, tc.expectedBinds, hostConf.Binds) assert.Equal(t, tc.expectedBinds, hostConf.Binds)
assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts) assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts)
assert.Len(t, hook.AllRecords(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) // every drop is warned about assert.Len(t, hook.AllRecords(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts))
}) })
} }
} }
@@ -645,8 +688,6 @@ func TestMergeContainerConfigsDropsVolumeDriversFromWorkflows(t *testing.T) {
assert.Equal(t, "job-escape", hostConfig.Mounts[0].Source) assert.Equal(t, "job-escape", hostConfig.Mounts[0].Source)
} }
// Both of these are read here, on the runner, so a workflow could read the runner's files
// and environment with them.
func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) { func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
hostFile := filepath.Join(t.TempDir(), "host.env") hostFile := filepath.Join(t.TempDir(), "host.env")
require.NoError(t, os.WriteFile(hostFile, []byte("STOLEN=from-the-host\n"), 0o600)) require.NoError(t, os.WriteFile(hostFile, []byte("STOLEN=from-the-host\n"), 0o600))
@@ -656,16 +697,12 @@ func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
logger := logging.New(logtest.New()) logger := logging.New(logtest.New())
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}} cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
_, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}) require.ErrorContains(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}), "not allowed in a workflow")
require.ErrorContains(t, err, "not allowed in a workflow")
// the runner reading its own files is what those options are for
cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}} cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}}
_, _, err = cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}) require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}))
require.NoError(t, err)
} }
// a bare name is no longer resolved from the runner's environment, for either source
logger := logging.New(logtest.New()) logger := logging.New(logtest.New())
cr := &containerReference{input: &NewContainerInput{ cr := &containerReference{input: &NewContainerInput{
NetworkMode: "bridge", NetworkMode: "bridge",
@@ -673,8 +710,8 @@ func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value", WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value",
}} }}
config, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{}) config := &container.Config{}
require.NoError(t, err) require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), config, &container.HostConfig{}))
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env) assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
} }
@@ -711,7 +748,6 @@ func TestSanitizeOptionsHostConfig(t *testing.T) {
assert.Equal(t, &container.HostConfig{}, hostConfig) assert.Equal(t, &container.HostConfig{}, hostConfig)
} }
// mergeOptions merges both option sources into a bare container, returning the result and its log.
func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *logtest.Handler) { func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *logtest.Handler) {
t.Helper() t.Helper()
logger, hook := logtest.NewLogger() logger, hook := logtest.NewLogger()
@@ -723,12 +759,12 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privilege
ValidVolumes: []string{"**"}, ValidVolumes: []string{"**"},
}} }}
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{ hostConfig := &container.HostConfig{
Privileged: privileged, Privileged: privileged,
UsernsMode: container.UsernsMode("private"), UsernsMode: container.UsernsMode("private"),
NetworkMode: container.NetworkMode("bridge"), NetworkMode: container.NetworkMode("bridge"),
}) }
require.NoError(t, err) require.NoError(t, cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, hostConfig))
return hostConfig, hook return hostConfig, hook
} }
@@ -782,23 +818,26 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
} }
escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied" escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied"
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{ hostConf := &container.HostConfig{
Binds: []string{escapingPath + ":/mnt"}, Binds: []string{escapingPath + ":/mnt"},
}) }
cr.sanitizeConfig(ctx, hostConf)
assert.Empty(t, hostConf.Binds) assert.Empty(t, hostConf.Binds)
linkPath := filepath.Join(allowed, "link") linkPath := filepath.Join(allowed, "link")
if err := os.Symlink(denied, linkPath); err != nil { if err := os.Symlink(denied, linkPath); err != nil {
t.Skipf("cannot create symlink: %v", err) t.Skipf("cannot create symlink: %v", err)
} }
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{ hostConf = &container.HostConfig{
Binds: []string{linkPath + ":/mnt"}, Binds: []string{linkPath + ":/mnt"},
}) }
cr.sanitizeConfig(ctx, hostConf)
assert.Empty(t, hostConf.Binds) assert.Empty(t, hostConf.Binds)
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{ hostConf = &container.HostConfig{
Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"}, Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"},
}) }
cr.sanitizeConfig(ctx, hostConf)
assert.Empty(t, hostConf.Binds) assert.Empty(t, hostConf.Binds)
} }
@@ -808,11 +847,12 @@ func TestSanitizeConfigKeepsTmpfsMounts(t *testing.T) {
for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} { for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} {
logger, hook := logtest.NewLogger() logger, hook := logtest.NewLogger()
_, sanitized := (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{Mounts: []mount.Mount{ sanitized := &container.HostConfig{Mounts: []mount.Mount{
hostConfig.Mounts[0], hostConfig.Mounts[0],
{Type: mount.TypeVolume, Target: "/anonymous"}, {Type: mount.TypeVolume, Target: "/anonymous"},
{Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"}, {Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"},
}}) }}
(&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(t.Context(), logger), sanitized)
assert.Equal(t, hostConfig.Mounts, sanitized.Mounts) assert.Equal(t, hostConfig.Mounts, sanitized.Mounts)
assert.Len(t, hook.AllRecords(), 2) assert.Len(t, hook.AllRecords(), 2)
} }
@@ -908,11 +948,11 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
}, },
} }
_, hostConf, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{ hostConf := &container.HostConfig{
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"}, Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}}, Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}},
}) }
require.NoError(t, err) require.NoError(t, cr.mergeContainerConfigs(ctx, &container.Config{}, hostConf))
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds) assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
assert.Empty(t, hostConf.Mounts) assert.Empty(t, hostConf.Mounts)
} }
@@ -964,8 +1004,7 @@ func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary", 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, 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) assert.Equal(t, []string{"redis", "redis-primary"}, cr.input.NetworkAliases)
} }
+1
View File
@@ -30,6 +30,7 @@ func TestSSHArgs(t *testing.T) {
} }
func TestDialCommandStreamsStdioAndReportsExit(t *testing.T) { func TestDialCommandStreamsStdioAndReportsExit(t *testing.T) {
t.Parallel()
if _, err := exec.LookPath("sh"); err != nil { if _, err := exec.LookPath("sh"); err != nil {
t.Skip("needs a POSIX shell") t.Skip("needs a POSIX shell")
} }
+46 -72
View File
@@ -7,6 +7,7 @@ package container
import ( import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -64,15 +65,11 @@ func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group {
} }
func (e *HostEnvironment) Create(_, _ []string) common.Executor { func (e *HostEnvironment) Create(_, _ []string) common.Executor {
return func(ctx context.Context) error { return common.NewPipelineExecutor()
return nil
}
} }
func (e *HostEnvironment) Close() common.Executor { func (e *HostEnvironment) Close() common.Executor {
return func(ctx context.Context) error { return common.NewPipelineExecutor()
return nil
}
} }
func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor { func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor {
@@ -168,15 +165,11 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
} }
func (e *HostEnvironment) Pull(_ bool) common.Executor { func (e *HostEnvironment) Pull(_ bool) common.Executor {
return func(ctx context.Context) error { return common.NewPipelineExecutor()
return nil
}
} }
func (e *HostEnvironment) Start(_ bool) common.Executor { func (e *HostEnvironment) Start(_ bool) common.Executor {
return func(ctx context.Context) error { return common.NewPipelineExecutor()
return nil
}
} }
type ptyWriter struct { type ptyWriter struct {
@@ -193,7 +186,6 @@ func (w *ptyWriter) Write(buf []byte) (int, error) {
} }
if w.dirtyLine || len(buf) > 1 && buf[len(buf)-2] != '\n' { if w.dirtyLine || len(buf) > 1 && buf[len(buf)-2] != '\n' {
_, _ = w.Out.Write([]byte("\n")) _, _ = w.Out.Write([]byte("\n"))
return n, io.EOF
} }
return n, io.EOF return n, io.EOF
} }
@@ -233,49 +225,38 @@ func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
return ppty, tty, nil return ppty, tty, nil
} }
func writeKeepAlive(ppty io.Writer) { func writeKeepAlive(ctx context.Context, ppty io.Writer) {
c := 1 for ctx.Err() == nil {
var err error if count, err := ppty.Write([]byte{4}); count != 1 || err != nil {
for c == 1 && err == nil { return
c, err = ppty.Write([]byte{4}) }
<-time.After(time.Second) select {
} case <-ctx.Done():
} return
case <-time.After(time.Second):
func copyPtyOutput(writer io.Writer, ppty io.Reader, finishLog context.CancelFunc) { }
defer func() {
finishLog()
}()
if _, err := io.Copy(writer, ppty); err != nil {
return
} }
} }
func (e *HostEnvironment) UpdateFromImageEnv(_ *map[string]string) common.Executor { func (e *HostEnvironment) UpdateFromImageEnv(_ *map[string]string) common.Executor {
return func(ctx context.Context) error { return common.NewPipelineExecutor()
return nil
}
} }
func getEnvListFromMap(env map[string]string) []string { func getEnvListFromMap(env map[string]string) []string {
envList := make([]string, 0) envList := make([]string, 0, len(env))
for k, v := range env { for key, value := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v)) envList = append(envList, key+"="+value)
} }
return envList return envList
} }
func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline string, env map[string]string, _, workdir string) error { func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline string, env map[string]string, workdir string) error {
envList := getEnvListFromMap(env) wd := e.Path
var wd string
if workdir != "" { if workdir != "" {
if filepath.IsAbs(workdir) { wd = workdir
wd = workdir if !filepath.IsAbs(workdir) {
} else {
wd = filepath.Join(e.Path, workdir) wd = filepath.Join(e.Path, workdir)
} }
} else {
wd = e.Path
} }
// Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend // Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend
// does in waitForCommand, so the final line of a command's output is not lost. // does in waitForCommand, so the final line of a command's output is not lost.
@@ -288,9 +269,8 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
cmd := exec.CommandContext(ctx, f) cmd := exec.CommandContext(ctx, f)
cmd.Path = f cmd.Path = f
cmd.Args = command cmd.Args = command
cmd.Stdin = nil
cmd.Stdout = e.StdOut cmd.Stdout = e.StdOut
cmd.Env = envList cmd.Env = getEnvListFromMap(env)
cmd.Stderr = e.StdOut cmd.Stderr = e.StdOut
cmd.Dir = wd cmd.Dir = wd
cmd.SysProcAttr = process.SysProcAttr(cmdline, false) cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
@@ -322,8 +302,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
writer = &ptyWriter{Out: e.StdOut} writer = &ptyWriter{Out: e.StdOut}
var finishLog context.CancelFunc var finishLog context.CancelFunc
logctx, finishLog = context.WithCancel(context.Background()) logctx, finishLog = context.WithCancel(context.Background())
go copyPtyOutput(writer, ppty, finishLog) go func() {
go writeKeepAlive(ppty) defer finishLog()
_, _ = io.Copy(writer, ppty)
}()
go writeKeepAlive(logctx, ppty)
} }
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return err return err
@@ -339,31 +322,31 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
defer k.Close() defer k.Close()
} }
err = treeKill.Wait(ctx, cmd) err = treeKill.Wait(ctx, cmd)
if err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return ExitCodeError(exitErr.ExitCode())
}
return err
}
if tty != nil { if tty != nil {
writer.AutoStop.Store(true) writer.AutoStop.Store(true)
if _, err := tty.WriteString("\x04"); err != nil { if _, err := tty.WriteString("\x04"); err != nil {
common.Logger(ctx).Debug("Failed to write EOT") common.Logger(ctx).Debug("Failed to write EOT")
} }
<-logctx.Done() select {
case <-logctx.Done():
case <-ctx.Done(): // a cancelled step does not wait on output nobody consumes
}
ppty.Close() ppty.Close()
ppty = nil ppty = nil
} }
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return ExitCodeError(exitErr.ExitCode())
}
return err return err
} }
func (e *HostEnvironment) Exec(command []string /*cmdline string, */, env map[string]string, user, workdir string) common.Executor { func (e *HostEnvironment) Exec(command []string, env map[string]string, _user, workdir string) common.Executor {
return e.ExecWithCmdLine(command, "", env, user, workdir) return e.ExecWithCmdLine(command, "", env, workdir)
} }
func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env map[string]string, user, workdir string) common.Executor { func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env map[string]string, workdir string) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if err := e.exec(ctx, command, cmdline, env, user, workdir); err != nil { if err := e.exec(ctx, command, cmdline, env, workdir); err != nil {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("this step has been cancelled: %w", err) return fmt.Errorf("this step has been cancelled: %w", err)
@@ -376,7 +359,7 @@ func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env
} }
func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
return parseEnvFile(e, srcPath, env) return parseEnvFile(e, srcPath, env, runtime.GOOS == "windows")
} }
// removeAll is a var so tests can substitute a blocking stub. // removeAll is a var so tests can substitute a blocking stub.
@@ -605,8 +588,7 @@ func (*HostEnvironment) GetPathVariableName() string {
} }
func (e *HostEnvironment) DefaultPathVariable() string { func (e *HostEnvironment) DefaultPathVariable() string {
v, _ := os.LookupEnv(e.GetPathVariableName()) return os.Getenv(e.GetPathVariableName())
return v
} }
func (*HostEnvironment) JoinPathVariable(paths ...string) string { func (*HostEnvironment) JoinPathVariable(paths ...string) string {
@@ -616,30 +598,22 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string {
// Reference for Arch values for runner.arch // Reference for Arch values for runner.arch
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context // https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
func goArchToActionArch(arch string) string { func goArchToActionArch(arch string) string {
archMapper := map[string]string{ return cmp.Or(map[string]string{
"x86_64": "X64", "x86_64": "X64",
"amd64": "X64", "amd64": "X64",
"386": "X86", "386": "X86",
"arm": "ARM", "arm": "ARM",
"aarch64": "ARM64", "aarch64": "ARM64",
"arm64": "ARM64", "arm64": "ARM64",
} }[arch], arch)
if arch, ok := archMapper[arch]; ok {
return arch
}
return arch
} }
func goOsToActionOs(os string) string { func goOsToActionOs(os string) string {
osMapper := map[string]string{ return cmp.Or(map[string]string{
"linux": "Linux", "linux": "Linux",
"darwin": "macOS", "darwin": "macOS",
"windows": "Windows", "windows": "Windows",
} }[os], os)
if os, ok := osMapper[os]; ok {
return os
}
return os
} }
func (e *HostEnvironment) GetRunnerContext(_ context.Context) map[string]any { func (e *HostEnvironment) GetRunnerContext(_ context.Context) map[string]any {
+120 -206
View File
@@ -10,11 +10,11 @@ import (
"context" "context"
"io" "io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"testing/synctest"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -24,7 +24,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// Type assert HostEnvironment implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &HostEnvironment{} var _ ExecutionsEnvironment = &HostEnvironment{}
func TestActionPlatformNames(t *testing.T) { func TestActionPlatformNames(t *testing.T) {
@@ -61,63 +60,68 @@ func TestCopyDir(t *testing.T) {
} }
func TestGetContainerArchive(t *testing.T) { func TestGetContainerArchive(t *testing.T) {
dir := t.TempDir()
ctx := t.Context() ctx := t.Context()
e := &HostEnvironment{ e := &HostEnvironment{Path: t.TempDir()}
Path: filepath.Join(dir, "path"),
TmpDir: filepath.Join(dir, "tmp"),
ToolCache: filepath.Join(dir, "tool_cache"),
ActPath: filepath.Join(dir, "act_path"),
StdOut: os.Stdout,
Workdir: path.Join("testdata", "scratch"),
}
_ = os.MkdirAll(e.Path, 0o700)
_ = os.MkdirAll(e.TmpDir, 0o700)
_ = os.MkdirAll(e.ToolCache, 0o700)
_ = os.MkdirAll(e.ActPath, 0o700)
expectedContent := []byte("sdde/7sh") expectedContent := []byte("sdde/7sh")
err := os.WriteFile(filepath.Join(e.Path, "action.yml"), expectedContent, 0o600) err := os.WriteFile(filepath.Join(e.Path, "action.yml"), expectedContent, 0o600)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
archive, err := e.GetContainerArchive(ctx, e.Path) archive, err := e.GetContainerArchive(ctx, e.Path)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
defer archive.Close() defer archive.Close()
reader := tar.NewReader(archive) reader := tar.NewReader(archive)
h, err := reader.Next() h, err := reader.Next()
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
assert.Equal(t, "action.yml", h.Name) assert.Equal(t, "action.yml", h.Name)
content, err := io.ReadAll(reader) content, err := io.ReadAll(reader)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
assert.Equal(t, expectedContent, content) assert.Equal(t, expectedContent, content)
_, err = reader.Next() _, err = reader.Next()
assert.ErrorIs(t, err, io.EOF) assert.ErrorIs(t, err, io.EOF)
} }
func TestHostEnvironmentExecExitCode(t *testing.T) { func TestHostEnvironmentExecExitCode(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell") t.Skip("uses POSIX shell")
} }
dir := t.TempDir() for name, allocatePTY := range map[string]bool{"without PTY": false, "with PTY": true} {
ctx := t.Context() t.Run(name, func(t *testing.T) {
e := &HostEnvironment{ t.Parallel()
Path: filepath.Join(dir, "path"), synctest.Test(t, func(t *testing.T) {
TmpDir: filepath.Join(dir, "tmp"), reader, writer := io.Pipe()
ToolCache: filepath.Join(dir, "tool_cache"), defer reader.Close()
ActPath: filepath.Join(dir, "act_path"), e := &HostEnvironment{Path: t.TempDir(), StdOut: writer, AllocatePTY: allocatePTY}
StdOut: io.Discard, t.Cleanup(func() { assert.NoError(t, e.Remove()(t.Context())) })
Workdir: filepath.Join(dir, "path"),
}
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
assert.NoError(t, os.MkdirAll(p, 0o700)) //nolint:testifylint // test setup
}
err := e.Exec([]string{"sh", "-c", "exit 3"}, map[string]string{"PATH": os.Getenv("PATH")}, "", "")(ctx) result := make(chan error, 1)
var exitErr ExitCodeError go func() {
require.ErrorAs(t, err, &exitErr) defer writer.Close()
assert.Equal(t, ExitCodeError(3), exitErr) result <- e.Exec([]string{"sh", "-c", "printf 'failed\\n'; exit 3"}, map[string]string{"PATH": os.Getenv("PATH")}, "", "")(t.Context())
assert.Equal(t, "Process completed with exit code 3.", err.Error()) }()
first := make([]byte, 1)
_, err := io.ReadFull(reader, first)
require.NoError(t, err)
synctest.Wait()
select {
case err := <-result:
t.Fatalf("Exec returned before its output was consumed: %v", err)
default:
}
remaining, err := io.ReadAll(reader)
require.NoError(t, err)
err = <-result
var exitErr ExitCodeError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, ExitCodeError(3), exitErr)
assert.Equal(t, "Process completed with exit code 3.", err.Error())
assert.Equal(t, "failed\n", string(first)+string(remaining))
})
})
}
} }
func TestHostEnvironmentAllocatePTY(t *testing.T) { func TestHostEnvironmentAllocatePTY(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell") t.Skip("uses POSIX shell")
} }
@@ -130,20 +134,13 @@ func TestHostEnvironmentAllocatePTY(t *testing.T) {
{name: "on", allocPTY: true, expect: "TTY"}, {name: "on", allocPTY: true, expect: "TTY"},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir() t.Parallel()
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
e := &HostEnvironment{ e := &HostEnvironment{
Path: filepath.Join(dir, "path"), Path: t.TempDir(),
TmpDir: filepath.Join(dir, "tmp"),
ToolCache: filepath.Join(dir, "tool_cache"),
ActPath: filepath.Join(dir, "act_path"),
StdOut: buf, StdOut: buf,
Workdir: filepath.Join(dir, "path"),
AllocatePTY: tc.allocPTY, AllocatePTY: tc.allocPTY,
} }
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
require.NoError(t, os.MkdirAll(p, 0o700))
}
err := e.Exec( err := e.Exec(
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"}, []string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
@@ -156,158 +153,91 @@ func TestHostEnvironmentAllocatePTY(t *testing.T) {
} }
} }
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) { func TestHostEnvironmentRemoveWorkdir(t *testing.T) {
ctx := common.WithLogger(t.Context(), logging.Default()) for name, owned := range map[string]bool{"preserves caller workdir": false, "cleans owned workdir": true} {
base := t.TempDir() t.Run(name, func(t *testing.T) {
miscRoot := filepath.Join(base, "misc") miscRoot, workdir := t.TempDir(), t.TempDir()
path := filepath.Join(miscRoot, "hostexecutor") e := &HostEnvironment{
require.NoError(t, os.MkdirAll(path, 0o700)) Path: filepath.Join(miscRoot, "hostexecutor"),
workdir := filepath.Join(base, "workspace", "owner", "repo") Workdir: workdir,
require.NoError(t, os.MkdirAll(workdir, 0o700)) CleanWorkdir: owned,
CleanUp: func() { _ = os.RemoveAll(miscRoot) },
e := &HostEnvironment{ }
Path: path, require.NoError(t, os.MkdirAll(e.Path, 0o700))
Workdir: workdir, require.NoError(t, e.Remove()(t.Context()))
CleanUp: func() { _, err := os.Stat(workdir)
_ = os.RemoveAll(miscRoot) if owned {
}, assert.ErrorIs(t, err, os.ErrNotExist)
StdOut: os.Stdout, } else {
require.NoError(t, err)
}
})
} }
require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir)
require.NoError(t, err)
}
func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
ctx := common.WithLogger(t.Context(), logging.Default())
base := t.TempDir()
miscRoot := filepath.Join(base, "misc")
path := filepath.Join(miscRoot, "hostexecutor")
require.NoError(t, os.MkdirAll(path, 0o700))
workdir := filepath.Join(base, "workspace", "123", "owner", "repo")
require.NoError(t, os.MkdirAll(workdir, 0o700))
e := &HostEnvironment{
Path: path,
Workdir: workdir,
CleanWorkdir: true,
CleanUp: func() {
_ = os.RemoveAll(miscRoot)
},
StdOut: os.Stdout,
}
require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir)
assert.ErrorIs(t, err, os.ErrNotExist)
} }
func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) { func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
release := make(chan struct{}) synctest.Test(t, func(t *testing.T) {
stubDone := make(chan struct{}) release := make(chan struct{})
stubDone := make(chan struct{})
orig := removeAll orig := removeAll
removeAll = func(string) error { removeAll = func(string) error {
defer close(stubDone) defer close(stubDone)
<-release <-release
return nil return nil
} }
// removeAllWithContext intentionally leaks the delete goroutine on timeout, t.Cleanup(func() {
// and that goroutine still references removeAll. Unblock it and wait for it close(release)
// to return before restoring the var, so the restore can't race the read. <-stubDone
t.Cleanup(func() { removeAll = orig
close(release) })
<-stubDone
removeAll = orig ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
err := removeAllWithContext(ctx, t.TempDir())
require.ErrorIs(t, err, context.DeadlineExceeded)
}) })
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
err := removeAllWithContext(ctx, t.TempDir())
require.ErrorIs(t, err, context.DeadlineExceeded)
} }
// TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp guards against a stalled func TestHostEnvironmentRemoveDoesNotHang(t *testing.T) {
// CleanUp callback (e.g. an os.RemoveAll blocked by an AV/EDR filter driver or for _, phase := range []string{"CleanUp callback", "path removal"} {
// an unresponsive mount) wedging the runner slot forever at "Cleaning up t.Run(phase, func(t *testing.T) {
// container". Remove must time out the callback and complete job teardown. synctest.Test(t, func(t *testing.T) {
func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) { origTimeout := hostCleanupTimeout
// Keep the suite fast: shrink the per-phase teardown timeout for this test. hostCleanupTimeout = 100 * time.Millisecond
orig := hostCleanupTimeout t.Cleanup(func() { hostCleanupTimeout = origTimeout })
hostCleanupTimeout = 100 * time.Millisecond release, stubDone := make(chan struct{}), make(chan struct{})
t.Cleanup(func() { hostCleanupTimeout = orig }) block := func() {
defer close(stubDone)
ctx := common.WithLogger(t.Context(), logging.Default()) <-release
base := t.TempDir() }
path := filepath.Join(base, "misc", "hostexecutor") e := &HostEnvironment{Path: filepath.Join(t.TempDir(), "misc", "hostexecutor"), StdOut: os.Stdout}
require.NoError(t, os.MkdirAll(path, 0o700)) if phase == "CleanUp callback" {
e.CleanUp = block
release := make(chan struct{}) } else {
t.Cleanup(func() { close(release) }) // unblock the leaked goroutine at test end origRemoveAll := removeAll
removeAll = func(string) error {
e := &HostEnvironment{ block()
Path: path, return nil
CleanUp: func() { }
<-release // simulate a delete syscall stuck indefinitely t.Cleanup(func() { removeAll = origRemoveAll })
}, }
StdOut: os.Stdout, t.Cleanup(func() {
} close(release)
<-stubDone
done := make(chan error, 1) })
go func() { done <- e.Remove()(ctx) }() require.NoError(t, os.MkdirAll(e.Path, 0o700))
done := make(chan error, 1)
select { go func() { done <- e.Remove()(common.WithLogger(t.Context(), logging.Default())) }()
case err := <-done: select {
require.NoError(t, err) case err := <-done:
case <-time.After(10 * time.Second): require.NoError(t, err)
t.Fatal("Remove() hung on a stuck CleanUp callback") case <-time.After(10 * time.Second):
} t.Fatalf("Remove() hung on a stuck %s", phase)
} }
})
// TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval guards against a })
// stalled os.RemoveAll on the misc/workspace paths (same AV/EDR wedge as
// #1023) wedging job completion after the CleanUp callback has already timed
// out or finished.
func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) {
origTimeout := hostCleanupTimeout
hostCleanupTimeout = 100 * time.Millisecond
t.Cleanup(func() { hostCleanupTimeout = origTimeout })
release := make(chan struct{})
stubDone := make(chan struct{})
origRemoveAll := removeAll
removeAll = func(string) error {
defer close(stubDone)
<-release
return nil
}
// The stuck delete goroutine outlives the timed-out Remove and still reads
// removeAll; unblock it and wait before restoring to avoid a restore/read race.
t.Cleanup(func() {
close(release)
<-stubDone
removeAll = origRemoveAll
})
ctx := common.WithLogger(t.Context(), logging.Default())
base := t.TempDir()
path := filepath.Join(base, "misc", "hostexecutor")
require.NoError(t, os.MkdirAll(path, 0o700))
e := &HostEnvironment{
Path: path,
StdOut: os.Stdout,
}
done := make(chan error, 1)
go func() { done <- e.Remove()(ctx) }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(10 * time.Second):
t.Fatal("Remove() hung on a stuck path removal")
} }
} }
@@ -315,17 +245,11 @@ func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
t.Run("single dir", func(t *testing.T) { t.Run("single dir", func(t *testing.T) {
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`}) s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
assert.Contains(t, s, `$paths = @('C:\workspace\job1')`) assert.Contains(t, s, `$paths = @('C:\workspace\job1')`)
// Self-PID guard is essential — without it the script could taskkill
// the PowerShell process running it.
assert.Contains(t, s, "$selfPid = $PID") assert.Contains(t, s, "$selfPid = $PID")
assert.Contains(t, s, "$_.ProcessId -eq $selfPid") assert.Contains(t, s, "$_.ProcessId -eq $selfPid")
// Must match both ExecutablePath (binaries from the workspace) and
// CommandLine (system binaries invoked with workspace paths in args),
// both bounded by dir+separator so a name-prefix sibling is spared.
assert.Contains(t, s, `$prefix = $p + '\'`) assert.Contains(t, s, `$prefix = $p + '\'`)
assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix") assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix")
assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix") assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix")
// Each matched PID must be tree-killed, not just stopped.
assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F") assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F")
}) })
@@ -338,25 +262,16 @@ func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
assert.Contains(t, s, `'C:\work\path'`) assert.Contains(t, s, `'C:\work\path'`)
assert.Contains(t, s, `'C:\work\workdir'`) assert.Contains(t, s, `'C:\work\workdir'`)
assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`) assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`)
// Commas between entries — no trailing comma, no leading comma.
assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`) assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`)
}) })
t.Run("path with single quote is escaped", func(t *testing.T) { t.Run("path with single quote is escaped", func(t *testing.T) {
// In PowerShell single-quoted strings the only special char is the
// quote itself, escaped by doubling. A workspace path that ever
// contained `'` would inject a command into the script otherwise.
s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`}) s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`})
assert.Contains(t, s, `'C:\work\it''s\path'`) assert.Contains(t, s, `'C:\work\it''s\path'`)
// And it must NOT appear unescaped — otherwise the quote would
// terminate the literal early.
assert.NotContains(t, s, `'C:\work\it's\path'`) assert.NotContains(t, s, `'C:\work\it's\path'`)
}) })
t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) { t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) {
// A path containing [ ] ? * must be embedded verbatim and matched with
// ordinal String methods, not -like, otherwise the metacharacters would
// be interpreted as wildcards and the leftover process could escape.
s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`}) s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`})
assert.Contains(t, s, `'C:\work\[job]?1'`) assert.Contains(t, s, `'C:\work\[job]?1'`)
assert.NotContains(t, s, "-like") assert.NotContains(t, s, "-like")
@@ -366,7 +281,6 @@ func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
t.Run("empty dir list still produces a valid script", func(t *testing.T) { t.Run("empty dir list still produces a valid script", func(t *testing.T) {
s := buildWindowsWorkspaceKillScript(nil) s := buildWindowsWorkspaceKillScript(nil)
// Empty array literal — script runs, matches nothing, is a no-op.
assert.Contains(t, s, "$paths = @()") assert.Contains(t, s, "$paths = @()")
assert.Contains(t, s, "Get-CimInstance Win32_Process") assert.Contains(t, s, "Get-CimInstance Win32_Process")
}) })
+61 -10
View File
@@ -7,6 +7,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"cmp" "cmp"
"compress/gzip"
"context" "context"
"crypto/rand" "crypto/rand"
"errors" "errors"
@@ -55,6 +56,7 @@ type kubernetesContainer struct {
name string name string
input *NewContainerInput input *NewContainerInput
probe map[string]any probe map[string]any
gzip bool // the container can gunzip, so copies into it go compressed
} }
type containerStatus struct { type containerStatus struct {
@@ -387,16 +389,44 @@ func (c *kubernetesContainer) Start(_ bool) common.Executor {
if err := c.pod.waitStarted(ctx); err != nil { if err := c.pod.waitStarted(ctx); err != nil {
return err return err
} }
var stdout bytes.Buffer output, err := c.pod.probe(ctx, c.name)
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `printf '%s\n%s' "$(uname -m)" "$PATH"`}, nil, &stdout, io.Discard); err != nil { if err != nil {
return err return err
} }
machine, path, _ := strings.Cut(stdout.String(), "\n") probed := strings.SplitN(output, "\n", 3)
c.pod.arch, c.pod.path = goArchToActionArch(machine), path for len(probed) < 3 {
probed = append(probed, "")
}
c.pod.arch, c.gzip, c.pod.path = goArchToActionArch(probed[0]), probed[1] == "gzip", probed[2]
return nil return nil
} }
} }
// probe retries exec while it fails short of the container, as a new node runs pods before its kubelet has a serving certificate.
func (p *KubernetesPod) probe(ctx context.Context, container string) (string, error) {
var failure string
deadline := time.Now().Add(time.Minute)
for delay := 100 * time.Millisecond; ; delay = min(2*delay, 2*time.Second) {
var stdout bytes.Buffer
err := p.client.exec(ctx, p.name, container, []string{"sh", "-c", `printf '%s\n%s\n%s' "$(uname -m)" "$(command -v gzip >/dev/null && echo gzip)" "$PATH"`}, nil, &stdout, io.Discard)
var exitErr ExitCodeError
var apiErr *apiError
if err == nil || ctx.Err() != nil || errors.As(err, &exitErr) || time.Now().After(deadline) ||
errors.As(err, &apiErr) && apiErr.code < http.StatusInternalServerError {
return stdout.String(), err
}
if err.Error() != failure {
failure = err.Error()
common.Logger(ctx).Infof("Waiting for exec into pod %s: %v", p.name, err)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(delay):
}
}
}
func (*kubernetesContainer) Pull(bool) common.Executor { func (*kubernetesContainer) Pull(bool) common.Executor {
return func(context.Context) error { return nil } return func(context.Context) error { return nil }
} }
@@ -427,12 +457,29 @@ func (c *kubernetesContainer) Exec(command []string, env map[string]string, _, w
} }
} }
func (c *kubernetesContainer) extract(ctx context.Context, destPath string, write func(io.Writer) error) error { // compressCopyMin is the size from which Copy compresses: below it gzip saves next to nothing on the wire and costs a process in the pod.
const compressCopyMin = 64 << 10
// extract streams the tar write produces into destPath over exec, which goes through the API server.
// With compress it is gzipped on the way, as copied actions shrink about fivefold and API server traffic often crosses zones.
func (c *kubernetesContainer) extract(ctx context.Context, destPath string, compress bool, write func(io.Writer) error) error {
compress = compress && c.gzip
reader, writer := io.Pipe() reader, writer := io.Pipe()
defer reader.Close() defer reader.Close()
go func() { writer.CloseWithError(write(writer)) }() go func() {
if !compress {
writer.CloseWithError(write(writer))
return
}
compressed, _ := gzip.NewWriterLevel(writer, gzip.BestSpeed) // fast enough to not slow copies down, nearly as small as the default level
writer.CloseWithError(errors.Join(write(compressed), compressed.Close()))
}()
script := `mkdir -p "$1" && tar -xf - -C "$1"`
if compress {
script = `mkdir -p "$1" && gzip -dc | tar -xf - -C "$1"` // gzip -dc rather than tar -z, which GNU tar runs gzip for anyway
}
var stderr bytes.Buffer var stderr bytes.Buffer
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `mkdir -p "$1" && tar -xf - -C "$1"`, "sh", destPath}, reader, io.Discard, &stderr); err != nil { if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", script, "sh", destPath}, reader, io.Discard, &stderr); err != nil {
return fmt.Errorf("extract to %s: %w: %s", destPath, err, strings.TrimSpace(stderr.String())) return fmt.Errorf("extract to %s: %w: %s", destPath, err, strings.TrimSpace(stderr.String()))
} }
return nil return nil
@@ -440,13 +487,17 @@ func (c *kubernetesContainer) extract(ctx context.Context, destPath string, writ
func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.Executor { func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
return c.extract(ctx, destPath, func(writer io.Writer) error { return writeFilesTar(ctx, writer, 0, 0, files...) }) size := 0
for _, file := range files {
size += len(file.Body)
}
return c.extract(ctx, destPath, size >= compressCopyMin, func(writer io.Writer) error { return writeFilesTar(ctx, writer, 0, 0, files...) })
} }
} }
func (c *kubernetesContainer) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor { func (c *kubernetesContainer) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
return c.extract(ctx, destPath, func(writer io.Writer) error { return c.extract(ctx, destPath, true, func(writer io.Writer) error { // directories are actions and workspaces, megabytes of compressible sources
return writeDirTar(ctx, writer, "/", srcPath, useGitIgnore, skipGitDir, 0, 0) return writeDirTar(ctx, writer, "/", srcPath, useGitIgnore, skipGitDir, 0, 0)
}) })
} }
@@ -475,7 +526,7 @@ func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath s
} }
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
return parseEnvFile(c, srcPath, env) return parseEnvFile(c, srcPath, env, false)
} }
func (c *kubernetesContainer) UpdateFromImageEnv(env *map[string]string) common.Executor { func (c *kubernetesContainer) UpdateFromImageEnv(env *map[string]string) common.Executor {
+115 -12
View File
@@ -43,8 +43,9 @@ type podManifest struct {
} }
type fakeCluster struct { type fakeCluster struct {
mu sync.Mutex mu sync.Mutex
requests map[string][]string requests map[string][]string
execFailures []http.HandlerFunc
} }
func (f *fakeCluster) record(request, content string) { func (f *fakeCluster) record(request, content string) {
@@ -53,7 +54,42 @@ func (f *fakeCluster) record(request, content string) {
f.requests[request] = append(f.requests[request], content) f.requests[request] = append(f.requests[request], content)
} }
func fakeKubernetes(t *testing.T, podStatus string) (string, *fakeCluster) { func (f *fakeCluster) failExec(failures ...http.HandlerFunc) {
f.mu.Lock()
defer f.mu.Unlock()
f.execFailures = failures
}
func (f *fakeCluster) nextExecFailure() http.HandlerFunc {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.execFailures) == 0 {
return nil
}
failure := f.execFailures[0]
f.execFailures = f.execFailures[1:]
return failure
}
// execFailure answers like the API server, code 0 for a failure reported after the websocket upgrade.
func execFailure(t *testing.T, code int, status string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if code != 0 {
w.WriteHeader(code)
_, _ = io.WriteString(w, status)
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{execProtocol}})
if err != nil {
t.Error(err)
return
}
defer func() { _ = conn.CloseNow() }()
_ = conn.Write(r.Context(), websocket.MessageBinary, append([]byte{3}, status...))
}
}
func fakeKubernetes(t *testing.T, podStatus string, env ...string) (string, *fakeCluster) {
cluster := &fakeCluster{requests: map[string][]string{}} cluster := &fakeCluster{requests: map[string][]string{}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" { if r.Header.Get("Authorization") != "Bearer test-token" {
@@ -70,7 +106,11 @@ func fakeKubernetes(t *testing.T, podStatus string) (string, *fakeCluster) {
_, _ = io.WriteString(w, `{"items":[{"metadata":{"name":"old","creationTimestamp":"2020-01-01T00:00:00Z"}},{"metadata":{"name":"new","creationTimestamp":"2999-01-01T00:00:00Z"}}]}`) _, _ = io.WriteString(w, `{"items":[{"metadata":{"name":"old","creationTimestamp":"2020-01-01T00:00:00Z"}},{"metadata":{"name":"new","creationTimestamp":"2999-01-01T00:00:00Z"}}]}`)
case strings.HasSuffix(r.URL.Path, "/exec"): case strings.HasSuffix(r.URL.Path, "/exec"):
cluster.record("exec", r.URL.RawQuery) cluster.record("exec", r.URL.RawQuery)
serveLocalExec(t, w, r) if failure := cluster.nextExecFailure(); failure != nil {
failure(w, r)
} else {
serveLocalExec(t, w, r, env)
}
case strings.HasSuffix(r.URL.Path, "/log"): case strings.HasSuffix(r.URL.Path, "/log"):
_, _ = io.WriteString(w, "log of "+r.URL.Query().Get("container")) _, _ = io.WriteString(w, "log of "+r.URL.Query().Get("container"))
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, pods+"/"): case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, pods+"/"):
@@ -92,7 +132,7 @@ users: [{name: test, user: {tokenFile: token}}]
return kubeconfig, cluster return kubeconfig, cluster
} }
func serveLocalExec(t *testing.T, w http.ResponseWriter, r *http.Request) { func serveLocalExec(t *testing.T, w http.ResponseWriter, r *http.Request, env []string) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{execProtocol}}) conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{execProtocol}})
if err != nil { if err != nil {
t.Error(err) t.Error(err)
@@ -114,7 +154,7 @@ func serveLocalExec(t *testing.T, w http.ResponseWriter, r *http.Request) {
stdin.Write(frame[1:]) stdin.Write(frame[1:])
} }
cmd := exec.Command(command[0], command[1:]...) cmd := exec.Command(command[0], command[1:]...)
cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1") cmd.Env = append(append(os.Environ(), "COPYFILE_DISABLE=1"), env...)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdin, cmd.Stdout, cmd.Stderr = &stdin, &stdout, &stderr cmd.Stdin, cmd.Stdout, cmd.Stderr = &stdin, &stdout, &stderr
status := `{"status":"Success"}` status := `{"status":"Success"}`
@@ -143,15 +183,17 @@ func TestKubernetesServiceHostnames(t *testing.T) {
} }
func TestKubernetesPodRunsJobThroughAPI(t *testing.T) { func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell") t.Skip("uses POSIX shell")
} }
ctx := t.Context() ctx := t.Context()
kubeconfig, cluster := fakeKubernetes(t, `{"spec":{"containers":[{},{}]},"status":{"phase":"Running","containerStatuses":[
{"name":"job","ready":true,"state":{"running":{}}},
{"name":"my-db","ready":true,"state":{"running":{}}}]}}`)
workdir, err := filepath.EvalSymlinks(t.TempDir()) workdir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
path := workdir + ":" + os.Getenv("PATH")
kubeconfig, cluster := fakeKubernetes(t, `{"spec":{"containers":[{},{}]},"status":{"phase":"Running","containerStatuses":[
{"name":"job","ready":true,"state":{"running":{}}},
{"name":"my-db","ready":true,"state":{"running":{}}}]}}`, "PATH="+path)
options := KubernetesOptions{Kubeconfig: kubeconfig, RunnerUUID: "runner-uuid", PodTemplates: []map[string]any{ options := KubernetesOptions{Kubeconfig: kubeconfig, RunnerUUID: "runner-uuid", PodTemplates: []map[string]any{
{"spec": map[string]any{ {"spec": map[string]any{
"automountServiceAccountToken": true, "automountServiceAccountToken": true,
@@ -212,8 +254,13 @@ func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], `"args":["postgres","-D","$$(PGDATA)"]`) assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], `"args":["postgres","-D","$$(PGDATA)"]`)
require.NoError(t, os.WriteFile(filepath.Join(workdir, "env"), []byte("#!/bin/sh\necho \"$@\" > \"$0-args\"\nexec /usr/bin/env \"$@\"\n"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(workdir, "env"), []byte("#!/bin/sh\necho \"$@\" > \"$0-args\"\nexec /usr/bin/env \"$@\"\n"), 0o755))
t.Setenv("PATH", workdir+":"+os.Getenv("PATH")) cluster.failExec(
execFailure(t, http.StatusInternalServerError, `{"message":"error dialing backend: remote error: tls: internal error"}`),
execFailure(t, 0, `{"status":"Failure","reason":"InternalError","message":"Internal error occurred: error sending request: remote error: tls: internal error"}`),
)
require.NoError(t, job.Start(false)(ctx)) require.NoError(t, job.Start(false)(ctx))
assert.Len(t, cluster.requests["exec"], 3)
assert.True(t, pod.job.gzip)
stepEnv := map[string]string{"INPUT_WHO-TO-GREET": "it's\nme", "SECRET": "hunter2", "UID": "5"} stepEnv := map[string]string{"INPUT_WHO-TO-GREET": "it's\nme", "SECRET": "hunter2", "UID": "5"}
require.NoError(t, job.Exec([]string{"printenv", "SECRET"}, stepEnv, "", "")(ctx)) require.NoError(t, job.Exec([]string{"printenv", "SECRET"}, stepEnv, "", "")(ctx))
@@ -230,7 +277,9 @@ func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
assert.NotContains(t, query, "hunter2") assert.NotContains(t, query, "hunter2")
} }
lastExec := func() string { return cluster.requests["exec"][len(cluster.requests["exec"])-1] }
require.NoError(t, job.Copy(workdir+"/act/", &FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: `{"ref":"main"}`})(ctx)) require.NoError(t, job.Copy(workdir+"/act/", &FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: `{"ref":"main"}`})(ctx))
assert.NotContains(t, lastExec(), "gzip")
archive, err := job.GetContainerArchive(ctx, workdir+"/act/workflow/event.json") archive, err := job.GetContainerArchive(ctx, workdir+"/act/workflow/event.json")
require.NoError(t, err) require.NoError(t, err)
reader := tar.NewReader(archive) reader := tar.NewReader(archive)
@@ -243,6 +292,12 @@ func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
require.NoError(t, archive.Close()) require.NoError(t, archive.Close())
_, err = job.GetContainerArchive(ctx, workdir+"/missing") _, err = job.GetContainerArchive(ctx, workdir+"/missing")
require.Error(t, err) require.Error(t, err)
large := strings.Repeat("a", compressCopyMin)
require.NoError(t, job.Copy(workdir+"/act/", &FileEntry{Name: "workflow/large", Mode: 0o644, Body: large})(ctx))
assert.Contains(t, lastExec(), url.QueryEscape("gzip -dc |"))
content, err = os.ReadFile(workdir + "/act/workflow/large")
require.NoError(t, err)
assert.Equal(t, large, string(content))
actionDir := filepath.Join(t.TempDir(), "action") actionDir := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(actionDir, 0o755)) require.NoError(t, os.MkdirAll(actionDir, 0o755))
@@ -251,11 +306,12 @@ func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
script, err := os.ReadFile(workdir + "/act/actions/sha/index.js") script, err := os.ReadFile(workdir + "/act/actions/sha/index.js")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "main()", string(script)) assert.Equal(t, "main()", string(script))
assert.Contains(t, cluster.requests["exec"][len(cluster.requests["exec"])-1], "command="+url.QueryEscape(workdir+"/act/actions/sha/")) assert.Contains(t, lastExec(), "command="+url.QueryEscape(workdir+"/act/actions/sha/"))
assert.Contains(t, lastExec(), url.QueryEscape("gzip -dc |"))
pathEnv := map[string]string{} pathEnv := map[string]string{}
require.NoError(t, job.UpdateFromImageEnv(&pathEnv)(ctx)) require.NoError(t, job.UpdateFromImageEnv(&pathEnv)(ctx))
assert.Equal(t, os.Getenv("PATH"), pathEnv["PATH"]) assert.Equal(t, path, pathEnv["PATH"])
info, err := service.Inspect(ctx) info, err := service.Inspect(ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -275,6 +331,38 @@ func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
assert.Empty(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/new"]) assert.Empty(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/new"])
} }
func TestKubernetesCopiesUncompressedWithoutGzip(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
ctx := t.Context()
workdir, bin := t.TempDir(), t.TempDir()
for _, tool := range []string{"uname", "tar", "mkdir"} { // all the job container needs but gzip, as on a minimal image
found, err := exec.LookPath(tool)
require.NoError(t, err)
require.NoError(t, os.Symlink(found, filepath.Join(bin, tool)))
}
kubeconfig, cluster := fakeKubernetes(t, `{"spec":{"containers":[{}]},"status":{"phase":"Running","containerStatuses":[
{"name":"job","ready":true,"state":{"running":{}}}]}}`, "PATH="+bin)
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig})
require.NoError(t, err)
job := pod.JobContainer(&NewContainerInput{Image: "node", WorkingDir: workdir})
require.NoError(t, job.Create(nil, nil)(ctx))
require.NoError(t, job.Start(false)(ctx))
assert.False(t, pod.job.gzip)
assert.Equal(t, bin, pod.path)
actionDir := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(actionDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(actionDir, "index.js"), []byte("main()"), 0o644))
require.NoError(t, job.CopyDir(workdir+"/act/actions/sha/", actionDir+"/", false, false)(ctx))
script, err := os.ReadFile(workdir + "/act/actions/sha/index.js")
require.NoError(t, err)
assert.Equal(t, "main()", string(script))
assert.NotContains(t, cluster.requests["exec"][len(cluster.requests["exec"])-1], "gzip")
}
func TestKubernetesPodFailsBeforeSteps(t *testing.T) { func TestKubernetesPodFailsBeforeSteps(t *testing.T) {
kubeconfig, _ := fakeKubernetes(t, `{"spec":{"containers":[{}]},"status":{"containerStatuses":[ kubeconfig, _ := fakeKubernetes(t, `{"spec":{"containers":[{}]},"status":{"containerStatuses":[
{"name":"job","state":{"waiting":{"reason":"ErrImagePull","message":"no such image"}}}]}}`) {"name":"job","state":{"waiting":{"reason":"ErrImagePull","message":"no such image"}}}]}}`)
@@ -295,6 +383,21 @@ func TestKubernetesPodFailsBeforeSteps(t *testing.T) {
} }
require.ErrorContains(t, job.Create(nil, nil)(t.Context()), test.err) require.ErrorContains(t, job.Create(nil, nil)(t.Context()), test.err)
} }
running, cluster := fakeKubernetes(t, `{"spec":{"containers":[{}]},"status":{"containerStatuses":[{"name":"job","state":{"running":{}}}]}}`)
for _, test := range []struct {
failure http.HandlerFunc
err string
}{
{execFailure(t, http.StatusForbidden, `{"message":"pods \"job\" is forbidden"}`), "is forbidden"},
{execFailure(t, 0, `{"status":"Failure","details":{"causes":[{"reason":"ExitCode","message":"126"}]}}`), ExitCodeError(126).Error()},
} {
cluster.failExec(test.failure)
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: running})
require.NoError(t, err)
job := pod.JobContainer(&NewContainerInput{Image: "node"})
require.NoError(t, job.Create(nil, nil)(t.Context()))
require.ErrorContains(t, job.Start(false)(t.Context()), test.err)
}
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig, Namespace: "ci"}) pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig, Namespace: "ci"})
require.NoError(t, err) require.NoError(t, err)
job := pod.JobContainer(&NewContainerInput{Image: "missing"}) job := pod.JobContainer(&NewContainerInput{Image: "missing"})
@@ -16,6 +16,8 @@ import (
type LinuxContainerEnvironmentExtensions struct{} type LinuxContainerEnvironmentExtensions struct{}
var windowsPathRegex = regexp.MustCompile(`^([a-zA-Z]):\\(.+)$`)
// Resolves the equivalent host path inside the container // Resolves the equivalent host path inside the container
// This is required for windows and WSL 2 to translate things like C:\Users\Myproject to /mnt/users/Myproject // This is required for windows and WSL 2 to translate things like C:\Users\Myproject to /mnt/users/Myproject
// For use in docker volumes and binds // For use in docker volumes and binds
@@ -31,8 +33,6 @@ func (*LinuxContainerEnvironmentExtensions) ToContainerPath(path string) string
return "" return ""
} }
// Test if the path is a windows path
windowsPathRegex := regexp.MustCompile(`^([a-zA-Z]):\\(.+)$`)
windowsPathComponents := windowsPathRegex.FindStringSubmatch(abspath) windowsPathComponents := windowsPathRegex.FindStringSubmatch(abspath)
// Return as-is if no match // Return as-is if no match
@@ -43,11 +43,7 @@ func (*LinuxContainerEnvironmentExtensions) ToContainerPath(path string) string
// Convert to WSL2-compatible path if it is a windows path // Convert to WSL2-compatible path if it is a windows path
// NOTE: Cannot use filepath because it will use the wrong path separators assuming we want the path to be windows // NOTE: Cannot use filepath because it will use the wrong path separators assuming we want the path to be windows
// based if running on Windows, and because we are feeding this to Docker, GoLang auto-path-translate doesn't work. // based if running on Windows, and because we are feeding this to Docker, GoLang auto-path-translate doesn't work.
driveLetter := strings.ToLower(windowsPathComponents[1]) return "/mnt/" + strings.ToLower(windowsPathComponents[1]) + "/" + strings.ReplaceAll(windowsPathComponents[2], `\`, `/`)
translatedPath := strings.ReplaceAll(windowsPathComponents[2], `\`, `/`)
// Should make something like /mnt/c/Users/person/My Folder/MyActProject
result := strings.Join([]string{"/mnt", driveLetter, translatedPath}, `/`)
return result
} }
func (*LinuxContainerEnvironmentExtensions) GetActPath() string { func (*LinuxContainerEnvironmentExtensions) GetActPath() string {
@@ -69,10 +65,41 @@ func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) st
// DefaultToolCache is where the runner mounts the tool cache inside job containers. // DefaultToolCache is where the runner mounts the tool cache inside job containers.
const DefaultToolCache = "/opt/hostedtoolcache" const DefaultToolCache = "/opt/hostedtoolcache"
type runnerArchKey struct{}
func WithRunnerArch(ctx context.Context) context.Context { // shares one architecture lookup per run
if ctx.Value(runnerArchKey{}) != nil {
return ctx
}
return context.WithValue(ctx, runnerArchKey{}, &runnerArchCache{lock: make(chan struct{}, 1)})
}
type runnerArchCache struct {
lock chan struct{} // lets cancelled jobs stop waiting for the cache
arch string
}
func cachedRunnerArch(ctx context.Context) string {
cache, ok := ctx.Value(runnerArchKey{}).(*runnerArchCache)
if !ok {
return RunnerArch(ctx)
}
select {
case cache.lock <- struct{}{}:
defer func() { <-cache.lock }()
case <-ctx.Done():
return ""
}
if cache.arch == "" {
cache.arch = RunnerArch(ctx)
}
return cache.arch
}
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any { func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
return map[string]any{ return map[string]any{
"os": "Linux", "os": "Linux",
"arch": RunnerArch(ctx), "arch": cachedRunnerArch(ctx),
"temp": "/tmp", "temp": "/tmp",
"tool_cache": DefaultToolCache, "tool_cache": DefaultToolCache,
} }
@@ -5,15 +5,13 @@
package container package container
import ( import (
"fmt"
"os" "os"
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"gitea.com/gitea/runner/act/common/logging"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestContainerPath(t *testing.T) { func TestContainerPath(t *testing.T) {
@@ -23,42 +21,27 @@ func TestContainerPath(t *testing.T) {
workDir string workDir string
} }
linuxcontainerext := &LinuxContainerEnvironmentExtensions{} cwd, err := os.Getwd()
require.NoError(t, err)
tests := []containerPathJob{
{"/home/act/go/src/github.com/nektos/act", "/home/act/go/src/github.com/nektos/act", ""},
{"/home/act", `/home/act/`, ""},
{cwd, ".", ""},
}
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
cwd, err := os.Getwd()
if err != nil {
logging.Error(err)
}
rootDrive := os.Getenv("SystemDrive") rootDrive := os.Getenv("SystemDrive")
rootDriveLetter := strings.ReplaceAll(strings.ToLower(rootDrive), `:`, "") tests = []containerPathJob{
for _, v := range []containerPathJob{
{"/mnt/c/Users/act/go/src/github.com/nektos/act", "C:\\Users\\act\\go\\src\\github.com\\nektos\\act\\", ""}, {"/mnt/c/Users/act/go/src/github.com/nektos/act", "C:\\Users\\act\\go\\src\\github.com\\nektos\\act\\", ""},
{"/mnt/f/work/dir", `F:\work\dir`, ""}, {"/mnt/f/work/dir", `F:\work\dir`, ""},
{"/mnt/c/windows/to/unix", "windows\\to\\unix", rootDrive + "\\"}, {"/mnt/c/windows/to/unix", "windows\\to\\unix", rootDrive + "\\"},
{fmt.Sprintf("/mnt/%v/act", rootDriveLetter), "act", rootDrive + "\\"}, {"/mnt/" + strings.ReplaceAll(strings.ToLower(rootDrive), `:`, "") + "/act", "act", rootDrive + "\\"},
} {
if v.workDir != "" {
t.Chdir(v.workDir)
}
assert.Equal(t, v.destinationPath, linuxcontainerext.ToContainerPath(v.sourcePath))
} }
}
t.Chdir(cwd) for _, test := range tests {
} else { if test.workDir != "" {
cwd, err := os.Getwd() t.Chdir(test.workDir)
if err != nil {
logging.Error(err)
}
for _, v := range []containerPathJob{
{"/home/act/go/src/github.com/nektos/act", "/home/act/go/src/github.com/nektos/act", ""},
{"/home/act", `/home/act/`, ""},
{cwd, ".", ""},
} {
assert.Equal(t, v.destinationPath, linuxcontainerext.ToContainerPath(v.sourcePath))
} }
assert.Equal(t, test.destinationPath, (&LinuxContainerEnvironmentExtensions{}).ToContainerPath(test.sourcePath))
} }
} }
@@ -67,5 +50,4 @@ type typeAssertMockContainer struct {
LinuxContainerEnvironmentExtensions LinuxContainerEnvironmentExtensions
} }
// Type assert Container + LinuxContainerEnvironmentExtensions implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &typeAssertMockContainer{} var _ ExecutionsEnvironment = &typeAssertMockContainer{}
+30 -5
View File
@@ -7,6 +7,7 @@ package container
import ( import (
"archive/tar" "archive/tar"
"bufio" "bufio"
"bytes"
"context" "context"
"fmt" "fmt"
"io" "io"
@@ -18,7 +19,8 @@ import (
"golang.org/x/text/transform" "golang.org/x/text/transform"
) )
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor { // parseEnvFile reads a GITHUB_ENV style file, splitting lines like GitHub's runner on Windows or elsewhere.
func parseEnvFile(e Container, srcPath string, env *map[string]string, windows bool) common.Executor {
localEnv := *env localEnv := *env
return func(ctx context.Context) error { return func(ctx context.Context) error {
envTar, err := e.GetContainerArchive(ctx, srcPath) envTar, err := e.GetContainerArchive(ctx, srcPath)
@@ -38,8 +40,22 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
s := bufio.NewScanner(decoded) s := bufio.NewScanner(decoded)
// Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB. // Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB.
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
scanned := 0
s.Split(func(data []byte, atEOF bool) (int, []byte, error) {
if index := bytes.IndexByte(data[scanned:], '\n'); index >= 0 {
advance := scanned + index + 1
scanned = 0
return advance, data[:advance], nil
}
scanned = len(data)
if atEOF && len(data) > 0 {
scanned = 0
return len(data), data, nil
}
return 0, nil, nil
})
for s.Scan() { for s.Scan() {
line := s.Text() line := trimEnvLineEnding(s.Text(), windows)
// GitHub's runner ignores blank lines // GitHub's runner ignores blank lines
if strings.TrimSpace(line) == "" { if strings.TrimSpace(line) == "" {
continue continue
@@ -52,10 +68,13 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
case multiLineEnv != -1: case multiLineEnv != -1:
var multiLineEnvContent []string var multiLineEnvContent []string
multiLineEnvDelimiter := line[multiLineEnv+2:] multiLineEnvDelimiter := line[multiLineEnv+2:]
if multiLineEnv == 0 || multiLineEnvDelimiter == "" {
return fmt.Errorf("invalid format '%v', name and delimiter must not be empty", line)
}
delimiterFound := false delimiterFound := false
for s.Scan() { for s.Scan() {
content := s.Text() content := s.Text()
if content == multiLineEnvDelimiter { if trimEnvLineEnding(content, windows) == multiLineEnvDelimiter {
delimiterFound = true delimiterFound = true
break break
} }
@@ -67,7 +86,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]] = strings.Join(multiLineEnvContent, "\n") localEnv[line[:multiLineEnv]] = trimEnvLineEnding(strings.Join(multiLineEnvContent, ""), windows)
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)
} }
@@ -75,7 +94,13 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
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)
} }
env = &localEnv
return nil return nil
} }
} }
func trimEnvLineEnding(line string, windows bool) string {
if windows && strings.HasSuffix(line, "\r\n") {
return line[:len(line)-2]
}
return strings.TrimSuffix(line, "\n")
}
+59 -99
View File
@@ -12,7 +12,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/unicode" "golang.org/x/text/encoding/unicode"
) )
@@ -22,117 +21,78 @@ func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
return e, filepath.Join(e.Path, "envfile") return e, filepath.Join(e.Path, "envfile")
} }
func TestParseEnvFileSingleLine(t *testing.T) { func TestParseEnvFile(t *testing.T) {
e, envPath := newTestHostEnv(t) t.Parallel()
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600)) largeValue := strings.Repeat("x", 2*1024*1024)
encodeUTF16 := func(endianness unicode.Endianness) string {
env := map[string]string{} content, err := unicode.UTF16(endianness, unicode.UseBOM).NewEncoder().Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context())) require.NoError(t, err)
assert.Equal(t, "bar", env["FOO"]) return string(content)
assert.Equal(t, "qux", env["BAZ"]) }
} for _, test := range []struct {
name, content string
func TestParseEnvFileMultiLine(t *testing.T) { want map[string]string
e, envPath := newTestHostEnv(t) }{
content := "FOO<<EOF\nline1\nline2\nEOF\n" {"single line", "FOO=bar\nBAZ=qux\n", map[string]string{"FOO": "bar", "BAZ": "qux"}},
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600)) {"single line CRLF and final bare CR", "FOO=bar\r\nBAZ=qux\r", map[string]string{"FOO": "bar\r", "BAZ": "qux\r"}},
{"single line without final newline", "FOO=bar", map[string]string{"FOO": "bar"}},
env := map[string]string{} {"blank lines", "\nFOO=bar\n\n \nBAZ=qux\n\n", map[string]string{"FOO": "bar", "BAZ": "qux"}},
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context())) {"multiline", "FOO<<EOF\nline1\nline2\nEOF\n", map[string]string{"FOO": "line1\nline2"}},
assert.Equal(t, "line1\nline2", env["FOO"]) {"multiline without final newline", "FOO<<EOF\nline1\r\nline2\r\nEOF", map[string]string{"FOO": "line1\r\nline2\r"}},
} {"empty multiline without final newline", "FOO<<EOF\nEOF", map[string]string{"FOO": ""}},
{"multiline blank lines", "FOO<<EOF\nline1\n\nline2\nEOF\n", map[string]string{"FOO": "line1\n\nline2"}},
func TestParseEnvFileLargeValueWithinLimit(t *testing.T) { {"multiline leading blank line", "FOO<<EOF\n\nline2\nEOF\n", map[string]string{"FOO": "\nline2"}},
e, envPath := newTestHostEnv(t) {"multiline CRLF", "FOO<<EOF\r\nline1\r\nline2\r\nEOF\r\n", map[string]string{"FOO": "line1\r\nline2\r"}},
big := strings.Repeat("x", 2*1024*1024) {"multiline mixed line endings", "FOO<<EOF\nline1\r\nline2\n\r\nEOF\n", map[string]string{"FOO": "line1\r\nline2\n\r"}},
content := "FOO<<EOF\n" + big + "\nEOF\n" {"large value within limit", "FOO<<EOF\n" + largeValue + "\nEOF\n", map[string]string{"FOO": largeValue}},
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600)) {"UTF8 BOM", "\xef\xbb\xbfFOO=bar\n", map[string]string{"FOO": "bar"}},
{"UTF16 little endian", encodeUTF16(unicode.LittleEndian), map[string]string{"FOO": "bar\r", "MULTI": "line1\r"}},
env := map[string]string{} {"UTF16 big endian", encodeUTF16(unicode.BigEndian), map[string]string{"FOO": "bar\r", "MULTI": "line1\r"}},
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context())) } {
assert.Equal(t, big, env["FOO"]) t.Run(test.name, func(t *testing.T) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte(test.content), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env, false)(t.Context()))
assert.Equal(t, test.want, env)
})
}
t.Run("Windows line endings", func(t *testing.T) {
hostEnv, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nline2\nEOF\r\n"), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(hostEnv, envPath, &env, true)(t.Context()))
assert.Equal(t, map[string]string{"FOO": "bar", "MULTI": "line1\r\nline2"}, env)
})
} }
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) { func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
t.Parallel()
e, envPath := newTestHostEnv(t) e, envPath := newTestHostEnv(t)
tooBig := strings.Repeat("x", 17*1024*1024) // over the 16 MiB cap require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n"+strings.Repeat("x", 17*1024*1024)+"\nEOF\n"), 0o600))
content := "FOO<<EOF\n" + tooBig + "\nEOF\n"
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
env := map[string]string{} env := map[string]string{}
err := parseEnvFile(e, envPath, &env)(t.Context()) err := parseEnvFile(e, envPath, &env, false)(t.Context())
require.ErrorIs(t, err, bufio.ErrTooLong) require.ErrorIs(t, err, bufio.ErrTooLong)
assert.Contains(t, err.Error(), "reading env file") assert.Contains(t, err.Error(), "reading env file")
} }
// Regression test: a blank line used to fail the job at "Complete Job", after func TestParseEnvFileInvalidHeredoc(t *testing.T) {
// every step had already been recorded as successful. for name, content := range map[string]string{
func TestParseEnvFileBlankLines(t *testing.T) { "missing delimiter": "FOO<<EOF\nline1\nline2\n",
e, envPath := newTestHostEnv(t) "empty name": "<<EOF\nline1\nEOF\n",
require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600)) "empty delimiter": "FOO<<\nline1\n\n",
"delimiter with carriage return": "FOO<<EOF\nline1\nEOF\r\n",
env := map[string]string{} } {
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context())) t.Run(name, func(t *testing.T) {
assert.Equal(t, "bar", env["FOO"])
assert.Equal(t, "qux", env["BAZ"])
}
// blank lines inside a heredoc value are content, not separators
func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\n\nline2\nEOF\n"), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
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)(t.Context()))
assert.Equal(t, "\nline2", env["FOO"])
}
func TestParseEnvFileUTF8BOM(t *testing.T) {
e, envPath := newTestHostEnv(t)
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("FOO=bar\n")...)
require.NoError(t, os.WriteFile(envPath, content, 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
assert.Equal(t, "bar", env["FOO"])
}
// Windows host mode: PowerShell 5.1 redirection writes UTF-16, which used to be
// unrecognisable as KEY=VALUE, so the writes were silently ignored.
func TestParseEnvFileUTF16(t *testing.T) {
tests := []struct {
name string
encoder *encoding.Encoder
}{
{"little endian", unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewEncoder()},
{"big endian", unicode.UTF16(unicode.BigEndian, unicode.UseBOM).NewEncoder()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e, envPath := newTestHostEnv(t) e, envPath := newTestHostEnv(t)
content, err := tt.encoder.Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n")) require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
require.NoError(t, err)
require.NoError(t, os.WriteFile(envPath, content, 0o600))
env := map[string]string{} env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context())) err := parseEnvFile(e, envPath, &env, false)(t.Context())
assert.Equal(t, "bar", env["FOO"]) require.Error(t, err)
assert.Equal(t, "line1", env["MULTI"]) assert.Contains(t, err.Error(), "delimiter")
assert.Empty(t, env)
}) })
} }
} }
func TestParseEnvFileMissingDelimiter(t *testing.T) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
env := map[string]string{}
err := parseEnvFile(e, envPath, &env)(t.Context())
require.Error(t, err)
assert.Contains(t, err.Error(), "delimiter")
}
-1
View File
@@ -1 +0,0 @@
testfile
+4 -9
View File
@@ -43,7 +43,6 @@ func (tc TarCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string,
// update the name to correctly reflect the desired destination when untaring // update the name to correctly reflect the desired destination when untaring
header.Name = path.Join(tc.DstDir, fpath) header.Name = path.Join(tc.DstDir, fpath)
header.Mode = int64(fi.Mode()) header.Mode = int64(fi.Mode())
header.ModTime = fi.ModTime()
header.Uid = tc.UID header.Uid = tc.UID
header.Gid = tc.GID header.Gid = tc.GID
@@ -58,10 +57,8 @@ func (tc TarCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string,
} }
// copy file data into tar writer // copy file data into tar writer
if _, err := io.Copy(tc.TarWriter, f); err != nil { _, err = io.Copy(tc.TarWriter, f)
return err return err
}
return nil
} }
type CopyCollector struct { type CopyCollector struct {
@@ -87,10 +84,8 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
return err return err
} }
defer df.Close() defer df.Close()
if _, err := io.Copy(df, f); err != nil { _, err = io.Copy(df, f)
return err return err
}
return nil
} }
type FileCollector struct { type FileCollector struct {
+25 -9
View File
@@ -28,12 +28,12 @@ var (
type pinnedKey struct{} type pinnedKey struct{}
// WithPinnedCheckout keeps a run's sha and ref fixed when HEAD moves. // WithPinnedCheckout keeps a run's sha, ref and repository fixed when HEAD moves, in pins unless ctx has some.
func WithPinnedCheckout(ctx context.Context) context.Context { func WithPinnedCheckout(ctx context.Context, pins *sync.Map) context.Context {
if ctx.Value(pinnedKey{}) != nil { if ctx.Value(pinnedKey{}) != nil {
return ctx return ctx
} }
return context.WithValue(ctx, pinnedKey{}, &sync.Map{}) return context.WithValue(ctx, pinnedKey{}, pins)
} }
func pinned(ctx context.Context, key string, lookup func() (string, error)) (string, error) { func pinned(ctx context.Context, key string, lookup func() (string, error)) (string, error) {
@@ -57,12 +57,29 @@ func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
case "pull_request_target": case "pull_request_target":
ghc.Ref = "refs/heads/" + ghc.BaseRef ghc.Ref = "refs/heads/" + ghc.BaseRef
case "pull_request", "pull_request_review", "pull_request_review_comment": case "pull_request", "pull_request_review", "pull_request_review_comment":
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"]) number := ghc.Event["number"]
if number == nil {
number = model.NestedMapLookup(ghc.Event, "pull_request", "number")
}
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", number)
if ghc.EventName == "pull_request" && ghc.Event["action"] == "closed" && model.NestedMapLookup(ghc.Event, "pull_request", "merged") == true {
ghc.Ref = "refs/heads/" + ghc.BaseRef
}
case "deployment", "deployment_status": case "deployment", "deployment_status":
ghc.Ref = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "ref")) ghc.Ref = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "ref"))
case "release": case "release":
ghc.Ref = "refs/tags/" + model.AsString(model.NestedMapLookup(ghc.Event, "release", "tag_name")) ghc.Ref = "refs/tags/" + model.AsString(model.NestedMapLookup(ghc.Event, "release", "tag_name"))
case "push", "create", "workflow_dispatch": case "create":
ghc.Ref = model.AsString(ghc.Event["ref"])
if ghc.Ref != "" && !strings.HasPrefix(ghc.Ref, "refs/") {
switch model.AsString(ghc.Event["ref_type"]) {
case "branch":
ghc.Ref = "refs/heads/" + ghc.Ref
case "tag":
ghc.Ref = "refs/tags/" + ghc.Ref
}
}
case "push", "workflow_dispatch":
ghc.Ref = model.AsString(ghc.Event["ref"]) ghc.Ref = model.AsString(ghc.Event["ref"])
default: default:
defaultBranch := model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch")) defaultBranch := model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
@@ -117,8 +134,7 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
if ghc.Sha == "" { if ghc.Sha == "" {
sha, err := pinned(ctx, "sha:"+repoPath, func() (string, error) { sha, err := pinned(ctx, "sha:"+repoPath, func() (string, error) {
_, revision, err := findGitRevision(ctx, repoPath) return findGitRevision(ctx, repoPath)
return revision, err
}) })
if err != nil { if err != nil {
logger.Warnf("unable to get git revision: %v", err) logger.Warnf("unable to get git revision: %v", err)
@@ -132,12 +148,12 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
// remote in repoPath when it is not set yet, and derives its owner. // remote in repoPath when it is not set yet, and derives its owner.
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, repoPath string) { func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, repoPath string) {
if ghc.Repository == "" { if ghc.Repository == "" {
repo, err := findGithubRepo(ctx, repoPath) repo, err := pinned(ctx, "repo:"+repoPath, func() (string, error) { return findGithubRepo(ctx, repoPath) })
if err != nil { if err != nil {
common.Logger(ctx).Warnf("unable to get git repo (repoPath: %v): %v", repoPath, err) common.Logger(ctx).Warnf("unable to get git repo (repoPath: %v): %v", repoPath, err)
return return
} }
ghc.Repository = repo ghc.Repository = repo
} }
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0] ghc.RepositoryOwner, _, _ = strings.Cut(ghc.Repository, "/")
} }
+55 -34
View File
@@ -7,30 +7,21 @@ package ghcontext
import ( import (
"context" "context"
"errors" "errors"
"sync"
"testing" "testing"
"gitea.com/gitea/runner/act/common/logging"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestSetRef(t *testing.T) { func TestSetRef(t *testing.T) {
logging.SetLevel(logging.LevelDebug)
oldFindGitRef := findGitRef oldFindGitRef := findGitRef
oldFindGitRevision := findGitRevision
defer func() { findGitRef = oldFindGitRef }() defer func() { findGitRef = oldFindGitRef }()
defer func() { findGitRevision = oldFindGitRevision }()
findGitRef = func(ctx context.Context, file string) (string, error) { findGitRef = func(_ctx context.Context, _file string) (string, error) {
return "refs/heads/master", nil return "refs/heads/master", nil
} }
findGitRevision = func(ctx context.Context, file string) (string, string, error) {
return "", "1234fakesha", nil
}
tables := []struct { tables := []struct {
eventName string eventName string
event map[string]any event map[string]any
@@ -51,6 +42,30 @@ func TestSetRef(t *testing.T) {
ref: "refs/pull/1234/merge", ref: "refs/pull/1234/merge",
refName: "1234/merge", refName: "1234/merge",
}, },
{
eventName: "pull_request_review",
event: map[string]any{"pull_request": map[string]any{"number": 1234.}},
ref: "refs/pull/1234/merge",
refName: "1234/merge",
},
{
eventName: "pull_request",
event: map[string]any{"number": 1234., "action": "closed", "pull_request": map[string]any{"merged": true}},
ref: "refs/heads/master",
refName: "master",
},
{
eventName: "pull_request",
event: map[string]any{"number": 1234., "action": "closed", "pull_request": map[string]any{"merged": false}},
ref: "refs/pull/1234/merge",
refName: "1234/merge",
},
{
eventName: "pull_request_review_comment",
event: map[string]any{"pull_request": map[string]any{"number": 1234.}},
ref: "refs/pull/1234/merge",
refName: "1234/merge",
},
{ {
eventName: "deployment", eventName: "deployment",
event: map[string]any{ event: map[string]any{
@@ -79,6 +94,24 @@ func TestSetRef(t *testing.T) {
ref: "refs/heads/somebranch", ref: "refs/heads/somebranch",
refName: "somebranch", refName: "somebranch",
}, },
{
eventName: "create",
event: map[string]any{"ref": "feature/new", "ref_type": "branch"},
ref: "refs/heads/feature/new",
refName: "feature/new",
},
{
eventName: "create",
event: map[string]any{"ref": "v1", "ref_type": "tag"},
ref: "refs/tags/v1",
refName: "v1",
},
{
eventName: "create",
event: map[string]any{"ref": "refs/tags/v1", "ref_type": "tag"},
ref: "refs/tags/v1",
refName: "v1",
},
{ {
eventName: "unknown", eventName: "unknown",
event: map[string]any{ event: map[string]any{
@@ -114,7 +147,7 @@ func TestSetRef(t *testing.T) {
} }
t.Run("no-default-branch", func(t *testing.T) { t.Run("no-default-branch", func(t *testing.T) {
findGitRef = func(ctx context.Context, file string) (string, error) { findGitRef = func(_ctx context.Context, _file string) (string, error) {
return "", errors.New("no default branch") return "", errors.New("no default branch")
} }
@@ -130,19 +163,15 @@ func TestSetRef(t *testing.T) {
} }
func TestSetSha(t *testing.T) { func TestSetSha(t *testing.T) {
logging.SetLevel(logging.LevelDebug)
oldFindGitRef := findGitRef oldFindGitRef := findGitRef
oldFindGitRevision := findGitRevision oldFindGitRevision := findGitRevision
oldFindGithubRepo := findGithubRepo
defer func() { findGitRef = oldFindGitRef }() defer func() { findGitRef = oldFindGitRef }()
defer func() { findGitRevision = oldFindGitRevision }() defer func() { findGitRevision = oldFindGitRevision }()
defer func() { findGithubRepo = oldFindGithubRepo }()
findGitRef = func(ctx context.Context, file string) (string, error) { findGitRevision = func(_ctx context.Context, _file string) (string, error) {
return "refs/heads/master", nil return "1234fakesha", nil
}
findGitRevision = func(ctx context.Context, file string) (string, string, error) {
return "", "1234fakesha", nil
} }
tables := []struct { tables := []struct {
@@ -177,11 +206,6 @@ func TestSetSha(t *testing.T) {
}, },
sha: "deployment-sha", sha: "deployment-sha",
}, },
{
eventName: "release",
event: map[string]any{},
sha: "1234fakesha",
},
{ {
eventName: "push", eventName: "push",
event: map[string]any{ event: map[string]any{
@@ -195,18 +219,12 @@ func TestSetSha(t *testing.T) {
event: map[string]any{}, event: map[string]any{},
sha: "1234fakesha", sha: "1234fakesha",
}, },
{
eventName: "no-event",
event: map[string]any{},
sha: "1234fakesha",
},
} }
for _, table := range tables { for _, table := range tables {
t.Run(table.eventName, func(t *testing.T) { t.Run(table.eventName, func(t *testing.T) {
ghc := &model.GithubContext{ ghc := &model.GithubContext{
EventName: table.eventName, EventName: table.eventName,
BaseRef: "master",
Event: table.event, Event: table.event,
} }
@@ -216,16 +234,19 @@ func TestSetSha(t *testing.T) {
}) })
} }
t.Run("pinned checkout keeps the first sha and ref", func(t *testing.T) { t.Run("pinned checkout keeps the first sha, ref and repository", func(t *testing.T) {
ctx := WithPinnedCheckout(t.Context()) ctx := WithPinnedCheckout(t.Context(), &sync.Map{})
for _, head := range []string{"first", "moved"} { for _, head := range []string{"first", "moved"} {
findGitRevision = func(context.Context, string) (string, string, error) { return "", head, nil } findGitRevision = func(context.Context, string) (string, error) { return head, nil }
findGitRef = func(context.Context, string) (string, error) { return "refs/heads/" + head, nil } findGitRef = func(context.Context, string) (string, error) { return "refs/heads/" + head, nil }
findGithubRepo = func(context.Context, string) (string, error) { return "owner/" + head, nil }
ghc := &model.GithubContext{Event: map[string]any{}} ghc := &model.GithubContext{Event: map[string]any{}}
SetSha(ctx, ghc, "/some/dir") SetSha(ctx, ghc, "/some/dir")
SetRef(ctx, ghc, "/some/dir") SetRef(ctx, ghc, "/some/dir")
SetRepositoryAndOwner(ctx, ghc, "/some/dir")
assert.Equal(t, "first", ghc.Sha) assert.Equal(t, "first", ghc.Sha)
assert.Equal(t, "refs/heads/first", ghc.Ref) assert.Equal(t, "refs/heads/first", ghc.Ref)
assert.Equal(t, "owner/first", ghc.Repository)
} }
}) })
} }
+11 -23
View File
@@ -13,35 +13,23 @@ import (
"testing" "testing"
) )
func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) { func TestLookPath2SearchesPathAndEmptyElementOrUsesDirectPath(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
exe := filepath.Join(dir, "tool") exe := filepath.Join(dir, "tool")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil { if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
got, err := LookPath2("tool", map[string]string{"PATH": string(filepath.ListSeparator) + dir}) for file, searchPath := range map[string]string{"tool": string(filepath.ListSeparator) + dir, exe: ""} {
if err != nil { t.Run(file, func(t *testing.T) {
t.Fatal(err) got, err := LookPath2(file, map[string]string{"PATH": searchPath})
} if err != nil {
if got != exe { t.Fatal(err)
t.Fatalf("LookPath2() = %q, want %q", got, exe) }
} if got != exe {
} t.Fatalf("LookPath2() = %q, want %q", got, exe)
}
func TestLookPath2DirectPathDoesNotSearchPath(t *testing.T) { })
dir := t.TempDir()
exe := filepath.Join(dir, "tool")
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
got, err := LookPath2(exe, map[string]string{"PATH": ""})
if err != nil {
t.Fatal(err)
}
if got != exe {
t.Fatalf("LookPath2() = %q, want %q", got, exe)
} }
} }
+3 -9
View File
@@ -28,21 +28,15 @@ func chkStat(file string) error {
} }
func hasExt(file string) bool { func hasExt(file string) bool {
i := strings.LastIndex(file, ".") return strings.LastIndex(file, ".") > strings.LastIndexAny(file, `:\/`)
if i < 0 {
return false
}
return strings.LastIndexAny(file, `:\/`) < i
} }
func findExecutable(file string, exts []string) (string, error) { func findExecutable(file string, exts []string) (string, error) {
if len(exts) == 0 { if len(exts) == 0 {
return file, chkStat(file) return file, chkStat(file)
} }
if hasExt(file) { if hasExt(file) && chkStat(file) == nil {
if chkStat(file) == nil { return file, nil
return file, nil
}
} }
for _, e := range exts { for _, e := range exts {
if f := file + e; chkStat(f) == nil { if f := file + e; chkStat(f) == nil {
+140 -84
View File
@@ -8,7 +8,6 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"embed"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -20,12 +19,15 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git" "gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
_ "embed"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/kballard/go-shellquote" "github.com/kballard/go-shellquote"
) )
@@ -47,11 +49,13 @@ type fileWriter func(filename string, data []byte, perm fs.FileMode) error
type runAction func(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor type runAction func(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor
//go:embed res/trampoline.js //go:embed res/trampoline.js
var trampoline embed.FS var trampoline string
var ( var (
ContainerImageExistsLocally = container.ImageExistsLocally ContainerImageExistsLocally = container.ImageExistsLocally
ContainerNewDockerBuildExecutor = container.NewDockerBuildExecutor ContainerNewDockerBuildExecutor = container.NewDockerBuildExecutor
dockerActionNamePattern = regexp.MustCompile("[^a-zA-Z0-9]")
inputEnvPattern = regexp.MustCompile("[^A-Z0-9-]")
) )
func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) {
@@ -71,52 +75,52 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath
reader, closer, err = readFile("action.yaml") reader, closer, err = readFile("action.yaml")
addError("action.yaml", err) addError("action.yaml", err)
if os.IsNotExist(err) { if os.IsNotExist(err) {
_, closer, err := readFile("Dockerfile") dockerfile := "Dockerfile"
addError("Dockerfile", err) _, closer, err := readFile(dockerfile)
addError(dockerfile, err)
if os.IsNotExist(err) {
dockerfile = "dockerfile"
_, closer, err = readFile(dockerfile)
addError(dockerfile, err)
}
if err == nil { if err == nil {
closer.Close() closer.Close()
action := &model.Action{ action := &model.Action{
Name: "(Synthetic)", Name: "(Synthetic)",
Runs: model.ActionRuns{ Runs: model.ActionRuns{
Using: "docker", Using: "docker",
Image: "Dockerfile", Image: dockerfile,
}, },
} }
logger.Debugf("Using synthetic action %v for Dockerfile", action) logger.Debugf("Using synthetic action %v for %s", action, dockerfile)
return action, nil return action, nil
} }
if step.With != nil { if val, ok := step.With["args"]; ok {
if val, ok := step.With["args"]; ok { if err := writeFile(filepath.Join(actionDir, actionPath, "trampoline.js"), []byte(trampoline), 0o644); err != nil {
var b []byte return nil, err
if b, err = trampoline.ReadFile("res/trampoline.js"); err != nil {
return nil, err
}
err2 := writeFile(filepath.Join(actionDir, actionPath, "trampoline.js"), b, 0o644)
if err2 != nil {
return nil, err2
}
action := &model.Action{
Name: "(Synthetic)",
Inputs: map[string]model.Input{
"cwd": {
Description: "(Actual working directory)",
Required: false,
Default: filepath.Join(actionDir, actionPath),
},
"command": {
Description: "(Actual program)",
Required: false,
Default: val,
},
},
Runs: model.ActionRuns{
Using: "node12",
Main: "trampoline.js",
},
}
logger.Debugf("Using synthetic action %v", action)
return action, nil
} }
action := &model.Action{
Name: "(Synthetic)",
Inputs: map[string]model.Input{
"args": {},
"cwd": {
Description: "(Actual working directory)",
Required: false,
Default: filepath.Join(actionDir, actionPath),
},
"command": {
Description: "(Actual program)",
Required: false,
Default: val,
},
},
Runs: model.ActionRuns{
Using: "node12",
Main: "trampoline.js",
},
}
logger.Debugf("Using synthetic action %v", action)
return action, nil
} }
} }
} }
@@ -125,8 +129,7 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath
} }
defer closer.Close() defer closer.Close()
action, err := model.ReadAction(reader) return model.ReadAction(reader)
return action, err
} }
func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error { func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error {
@@ -151,12 +154,17 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
} }
defer unlock() defer unlock()
if !rc.Config.NoActionPatch { if err := rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", false, true)(ctx); err != nil { // an exported commit has no .git to apply gitignore with
// Other jobs share the directory, so patch under the copy's lock. return err
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
} }
if rc.Config.NoActionPatch {
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", false, true)(ctx) // an exported commit has no .git to apply gitignore with return nil
}
patched := patchedScripts(ctx, actionDir, actionScriptPaths(actionDir, filepath.Join(actionDir, actionPath), step.getActionModel()))
if len(patched) == 0 {
return nil
}
return rc.JobContainer.Copy(containerActionDirCopy, patched...)(ctx)
} }
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor { func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
@@ -166,7 +174,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
actionPath := "" actionPath := ""
if remoteAction != nil && remoteAction.Path != "" { if remoteAction != nil {
actionPath = remoteAction.Path actionPath = remoteAction.Path
} }
@@ -177,8 +185,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil { if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
return err return err
} }
warnUnexpectedInputs(ctx, action, stepModel.With)
printRunActionHeader(ctx, stepModel, action, *step.getEnv(), rc)
actionLocation := path.Join(actionDir, actionPath)
actionName, containerActionDir := getContainerActionPaths(step) actionName, containerActionDir := getContainerActionPaths(step)
logger.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir) logger.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir)
@@ -196,7 +205,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx) return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker(): case x.IsDocker():
location := actionLocation location := path.Join(actionDir, actionPath)
if remoteAction == nil { if remoteAction == nil {
location = containerActionDir location = containerActionDir
} }
@@ -215,12 +224,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
rc.ApplyExtraPath(ctx, step.getEnv()) rc.ApplyExtraPath(ctx, step.getEnv())
execFileName := action.Runs.Main + ".out" execFileName := action.Runs.Main + ".out"
buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Main}
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir), rc.JobContainer.Exec([]string{"go", "build", "-o", execFileName, action.Runs.Main}, *step.getEnv(), "", containerActionDir),
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""), rc.JobContainer.Exec([]string{filepath.Join(containerActionDir, execFileName)}, *step.getEnv(), "", ""),
)(ctx) )(ctx)
default: default:
return fmt.Errorf("the runs.using key must be one of: %v, got %s", []string{ return fmt.Errorf("the runs.using key must be one of: %v, got %s", []string{
@@ -259,16 +265,14 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin
name = path.Join(repository, actionName) name = path.Join(repository, actionName)
} }
// The human-readable name is sanitized by collapsing every non-alphanumeric character to "-". // The human-readable name is sanitized by collapsing every non-alphanumeric character to "-".
sanitized := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(name, "-") sanitized := dockerActionNamePattern.ReplaceAllString(name, "-")
if localAction { if localAction {
// For local actions a short hash of the raw repository and action path is appended so the tag stays unique per repository. // For local actions a short hash of the raw repository and action path is appended so the tag stays unique per repository.
sum := sha256.Sum256([]byte(repository + "\x00" + actionName)) sum := sha256.Sum256([]byte(repository + "\x00" + actionName))
sanitized += "-" + hex.EncodeToString(sum[:])[:12] sanitized += "-" + hex.EncodeToString(sum[:])[:12]
} }
// "-dockeraction" ensures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names // "-dockeraction" ensures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
image := fmt.Sprintf("%s-dockeraction:%s", sanitized, "latest") return strings.ToLower("act-" + strings.TrimLeft(sanitized+"-dockeraction:latest", "-"))
image = "act-" + strings.TrimLeft(image, "-")
return strings.ToLower(image)
} }
// One image per step and job: its stages share a build, and concurrent jobs never run each other's. // One image per step and job: its stages share a build, and concurrent jobs never run each other's.
@@ -312,9 +316,12 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
return err return err
} }
correctArchExists, err := ContainerImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture) correctArchExists := anyArchExists
if err != nil { if anyArchExists && rc.Config.ContainerArchitecture != "" && rc.Config.ContainerArchitecture != "any" {
return err correctArchExists, err = ContainerImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture)
if err != nil {
return err
}
} }
if anyArchExists && !correctArchExists { if anyArchExists && !correctArchExists {
@@ -361,14 +368,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture) logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
} }
} }
cmd, err := shellquote.Split(step.getStepModel().With["args"])
if err != nil {
return err
}
ee, err := evalDockerEnv(ctx, step, action) ee, err := evalDockerEnv(ctx, step, action)
if err != nil { if err != nil {
return err return err
} }
var cmd []string
if action.Runs.Args != nil { if action.Runs.Args != nil {
// a fresh slice, the manifest is evaluated again for every stage // a fresh slice, the manifest is evaluated again for every stage
cmd = make([]string, len(action.Runs.Args)) cmd = make([]string, len(action.Runs.Args))
@@ -377,6 +381,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
return fmt.Errorf("unable to interpolate runs.args: %w", err) return fmt.Errorf("unable to interpolate runs.args: %w", err)
} }
} }
} else {
cmd, err = shellquote.Split(stepInput(step.getStepModel(), "args"))
if err != nil {
return err
}
} }
entrypoint, err := dockerEntrypoint(step, stage) entrypoint, err := dockerEntrypoint(step, stage)
if err != nil { if err != nil {
@@ -406,7 +415,7 @@ func dockerEntrypoint(step actionStep, stage stepStage) ([]string, error) {
default: default:
entrypoint = runs.Entrypoint entrypoint = runs.Entrypoint
if entrypoint == "" { if entrypoint == "" {
if fields := strings.Fields(step.getStepModel().With["entrypoint"]); len(fields) > 0 { if fields := strings.Fields(stepInput(step.getStepModel(), "entrypoint")); len(fields) > 0 {
return fields, nil return fields, nil
} }
} }
@@ -424,9 +433,15 @@ func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expre
var err error var err error
inputs := make(map[string]string) inputs := make(map[string]string)
providedInputs := make(map[string]bool, len(step.getStepModel().With))
for key := range step.getStepModel().With {
providedInputs[strings.ToLower(key)] = true
}
eval := rc.NewExpressionEvaluator(ctx) eval := rc.NewExpressionEvaluator(ctx)
// Set Defaults
for k, input := range action.Inputs { for k, input := range action.Inputs {
if providedInputs[strings.ToLower(k)] {
continue
}
if inputs[k], err = eval.Interpolate(ctx, input.Default); err != nil { 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) return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
} }
@@ -485,20 +500,19 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
} }
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) { func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
state, ok := rc.IntraActionState[step.getStepModel().ID] for name, value := range rc.IntraActionState[step.getStepModel().ID] {
if ok { (*env)["STATE_"+name] = value
for name, value := range state {
envName := "STATE_" + name
(*env)[envName] = value
}
} }
} }
func inputEnvKey(inputID string) string {
return "INPUT_" + inputEnvPattern.ReplaceAllString(strings.ToUpper(inputID), "_")
}
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error { 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 := inputEnvKey(inputID)
envKey = "INPUT_" + envKey
if _, ok := (*env)[envKey]; !ok { if _, ok := (*env)[envKey]; !ok {
var err error var err error
if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil { if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil {
@@ -509,6 +523,47 @@ func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *
return nil return nil
} }
// lookupFold finds name in values, ignoring case where the environment does, as GitHub's inputs always do.
func lookupFold[V any](values map[string]V, name string, caseInsensitive bool) (V, bool) {
if value, ok := values[name]; ok || !caseInsensitive {
return value, ok
}
for key, value := range values {
if strings.EqualFold(key, name) {
return value, true
}
}
var zero V
return zero, false
}
func stepInput(step *model.Step, name string) string {
value, _ := lookupFold(step.With, name, true)
return value
}
func warnUnexpectedInputs(ctx context.Context, action *model.Action, inputs map[string]string) {
docker := action.Runs.Using.IsDocker()
var unexpectedInputs []string
for input := range inputs {
_, declared := lookupFold(action.Inputs, input, true)
containerInput := docker && (strings.EqualFold(input, "entryPoint") || strings.EqualFold(input, "args"))
if !declared && !containerInput {
unexpectedInputs = append(unexpectedInputs, input)
}
}
if len(unexpectedInputs) == 0 {
return
}
validInputs := slices.Sorted(maps.Keys(action.Inputs))
if docker {
validInputs = append([]string{"entryPoint", "args"}, validInputs...)
}
slices.Sort(unexpectedInputs)
common.Logger(ctx).With(rawOutputField, true).Warnf("##[warning]%s", EscapeCommandData(fmt.Sprintf(
"Unexpected input(s) '%s', valid inputs are ['%s']", strings.Join(unexpectedInputs, "', '"), strings.Join(validInputs, "', '"))))
}
func getContainerActionPaths(step actionStep) (string, string) { func getContainerActionPaths(step actionStep) (string, string) {
rc := step.getRunContext() rc := step.getRunContext()
if sar, ok := step.(*stepActionRemote); ok { // a copy per action, as its repository's directory is shared if sar, ok := step.(*stepActionRemote); ok { // a copy per action, as its repository's directory is shared
@@ -601,6 +656,7 @@ func runPreStep(step actionStep) common.Executor {
return err return err
} }
} }
printRunActionHeader(ctx, step.getStepModel(), action, *step.getEnv(), rc)
switch { switch {
case x.IsNode(): case x.IsNode():
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
@@ -637,12 +693,9 @@ func runPreStep(step actionStep) common.Executor {
rc.ApplyExtraPath(ctx, step.getEnv()) rc.ApplyExtraPath(ctx, step.getEnv())
execFileName := action.Runs.Pre + ".out" execFileName := action.Runs.Pre + ".out"
buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Pre}
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir), rc.JobContainer.Exec([]string{"go", "build", "-o", execFileName, action.Runs.Pre}, *step.getEnv(), "", containerActionDir),
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""), rc.JobContainer.Exec([]string{filepath.Join(containerActionDir, execFileName)}, *step.getEnv(), "", ""),
)(ctx) )(ctx)
default: default:
return nil return nil
@@ -652,8 +705,11 @@ func runPreStep(step actionStep) common.Executor {
func shouldRunPostStep(step actionStep) common.Conditional { func shouldRunPostStep(step actionStep) common.Conditional {
return func(ctx context.Context) bool { return func(ctx context.Context) bool {
if remote, ok := step.(*stepActionRemote); ok && remote.preStepStarted && !remote.action.Runs.Using.IsComposite() {
return true
}
log := common.Logger(ctx) log := common.Logger(ctx)
stepResults := step.getRunContext().getStepsContext() stepResults := step.getRunContext().StepResults
stepResult := stepResults[step.getStepModel().ID] stepResult := stepResults[step.getStepModel().ID]
if stepResult == nil { if stepResult == nil {
@@ -699,6 +755,9 @@ func runPostStep(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() { // a composite's children print their own
logger.With(rawOutputField, true).Info("Post job cleanup.")
}
switch { switch {
case x.IsNode(): case x.IsNode():
@@ -731,12 +790,9 @@ func runPostStep(step actionStep) common.Executor {
rc.ApplyExtraPath(ctx, step.getEnv()) rc.ApplyExtraPath(ctx, step.getEnv())
execFileName := action.Runs.Post + ".out" execFileName := action.Runs.Post + ".out"
buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Post}
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir), rc.JobContainer.Exec([]string{"go", "build", "-o", execFileName, action.Runs.Post}, *step.getEnv(), "", containerActionDir),
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""), rc.JobContainer.Exec([]string{filepath.Join(containerActionDir, execFileName)}, *step.getEnv(), "", ""),
)(ctx) )(ctx)
default: default:
+15 -21
View File
@@ -8,7 +8,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"regexp"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -32,8 +31,7 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
ee := parent.NewStepExpressionEvaluator(ctx, step) ee := parent.NewStepExpressionEvaluator(ctx, step)
for inputID, input := range step.getActionModel().Inputs { for inputID, input := range step.getActionModel().Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") envKey := inputEnvKey(inputID)
envKey = "INPUT_" + strings.ToUpper(envKey)
// 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
@@ -54,6 +52,11 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
} }
} }
} }
for envKey, value := range (&model.Step{With: step.getStepModel().With}).GetEnv() {
if _, ok := env[envKey]; !ok {
env[envKey] = value
}
}
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
@@ -107,6 +110,7 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
kubernetes: parent.kubernetes, kubernetes: parent.kubernetes,
jobIndex: parent.jobIndex, jobIndex: parent.jobIndex,
jobTotal: parent.jobTotal, jobTotal: parent.jobTotal,
runtimeToken: parent.runtimeToken,
} }
compositerc.setCompositeActionEnv(env) compositerc.setCompositeActionEnv(env)
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx) compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
@@ -211,19 +215,16 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
} }
stepID := step.getStepModel().ID stepID := step.getStepModel().ID
stepPre := rc.newCompositeCommandExecutor(step.pre()) preSteps = append(preSteps, newCompositeStepLogExecutor(rc.newCompositeCommandExecutor(step.pre()), stepID))
preSteps = append(preSteps, newCompositeStepLogExecutor(stepPre, stepID))
steps = append(steps, newCompositeStepLogExecutor(rc.newCompositeCommandExecutor(step.main()), stepID)) steps = append(steps, newCompositeStepLogExecutor(rc.newCompositeCommandExecutor(step.main()), stepID))
// run the post executor in reverse order // run the post executor in reverse order
stepPost := rc.newCompositeCommandExecutor(step.post())
if postExecutor != nil { if postExecutor != nil {
stepPost := rc.newCompositeCommandExecutor(step.post()) stepPost = stepPost.Finally(postExecutor)
postExecutor = newCompositeStepLogExecutor(stepPost.Finally(postExecutor), stepID)
} else {
stepPost := rc.newCompositeCommandExecutor(step.post())
postExecutor = newCompositeStepLogExecutor(stepPost, stepID)
} }
postExecutor = newCompositeStepLogExecutor(stepPost, stepID)
} }
steps = append(steps, common.JobError) steps = append(steps, common.JobError)
@@ -241,14 +242,7 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
func (rc *RunContext) newCompositeCommandExecutor(executor common.Executor) common.Executor { func (rc *RunContext) newCompositeCommandExecutor(executor common.Executor) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
ctx = WithCompositeLogger(ctx, &rc.Masks) return rc.withCommandLogWriter(WithCompositeLogger(ctx, &rc.Masks), executor)
logWriter := rc.commandLogWriter(ctx)
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
return executor(ctx)
} }
} }
@@ -257,12 +251,12 @@ func newCompositeStepLogExecutor(runStep common.Executor, stepID string) common.
ctx = WithCompositeStepLogger(ctx, stepID) ctx = WithCompositeStepLogger(ctx, stepID)
logger := common.Logger(ctx) logger := common.Logger(ctx)
err := runStep(ctx) err := runStep(ctx)
if err == nil {
err = ctx.Err()
}
if err != nil { if err != nil {
logger.Errorf("##[error]%s", EscapeCommandData(err.Error())) logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
common.SetJobError(ctx, err) common.SetJobError(ctx, err)
} else if ctx.Err() != nil {
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
common.SetJobError(ctx, ctx.Err())
} }
return nil return nil
} }
+20 -37
View File
@@ -7,7 +7,10 @@ import (
"context" "context"
"testing" "testing"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git" "gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -17,10 +20,11 @@ import (
func TestCompositeActionParity(t *testing.T) { func TestCompositeActionParity(t *testing.T) {
t.Run("inherits contexts without leaking inputs", func(t *testing.T) { t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
ctx := t.Context() hook := logtest.NewWithLevel(logging.LevelWarn)
ctx := common.WithLogger(t.Context(), logging.New(hook))
strategy := &model.Strategy{MaxParallelString: "3", RawMatrix: yaml.Node{Kind: yaml.MappingNode}} strategy := &model.Strategy{MaxParallelString: "3", RawMatrix: yaml.Node{Kind: yaml.MappingNode}}
parent := &RunContext{ parent := &RunContext{
Config: &Config{}, Config: &Config{PresetGitHubContext: &model.GithubContext{}},
Matrix: map[string]any{"os": "linux"}, Matrix: map[string]any{"os": "linux"},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
JobContainer: &jobContainerMock{}, JobContainer: &jobContainerMock{},
@@ -28,21 +32,26 @@ func TestCompositeActionParity(t *testing.T) {
jobIndex: 1, jobIndex: 1,
jobTotal: 4, jobTotal: 4,
} }
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{ step := &stepActionLocal{
Step: &model.Step{With: map[string]string{"SHARED": "outer"}}, Step: &model.Step{Uses: "./action", With: map[string]string{"SHARED": "outer", "extra": "literal${{ github.job }}"}},
RunContext: parent, RunContext: parent,
action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}}, action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}, Runs: model.ActionRuns{Using: "composite"}},
env: map[string]string{"INPUT_SHARED": "outer"}, env: map[string]string{"INPUT_SHARED": "outer"},
}, "/action") }
require.NoError(t, runActionImpl(step, "/action", nil)(ctx))
require.Len(t, hook.AllRecords(), 1)
assert.Equal(t, "##[warning]Unexpected input(s) 'extra', valid inputs are ['shared']", hook.LastRecord().Message)
composite, err := step.getCompositeRunContext(ctx)
require.NoError(t, err) require.NoError(t, err)
assert.Same(t, strategy, composite.Run.Job().Strategy) assert.Same(t, strategy, composite.Run.Job().Strategy)
assert.True(t, composite.IsHostEnv()) assert.True(t, composite.IsHostEnv())
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx, interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ strategy.job-index }}|${{ inputs.shared }}") "${{ matrix.os }}|${{ strategy.max-parallel }}|${{ strategy.job-index }}|${{ inputs.shared }}|${{ inputs.extra }}")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "linux|3|1|outer", interpolated) assert.Equal(t, "linux|3|1|outer|literal${{ github.job }}", interpolated)
assert.NotContains(t, composite.Env, "INPUT_SHARED") assert.NotContains(t, composite.Env, "INPUT_SHARED")
assert.NotContains(t, composite.Env, "INPUT_EXTRA")
nestedEnv := composite.GetEnv() nestedEnv := composite.GetEnv()
require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite)) require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite))
@@ -54,7 +63,7 @@ func TestCompositeActionParity(t *testing.T) {
return "", "", assert.AnError return "", "", assert.AnError
}) })
rc := &RunContext{ rc := &RunContext{
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir()}, Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir(), PresetGitHubContext: &model.GithubContext{}},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
JobContainer: &jobContainerMock{}, JobContainer: &jobContainerMock{},
} }
@@ -71,9 +80,9 @@ func TestAppendUniqueMasks(t *testing.T) {
want []string want []string
}{ }{
{ {
name: "appends new masks", name: "appends each new mask once",
dst: []string{"a"}, dst: []string{"a"},
src: []string{"b", "c"}, src: []string{"b", "c", "b", "a"},
want: []string{"a", "b", "c"}, want: []string{"a", "b", "c"},
}, },
{ {
@@ -82,12 +91,6 @@ func TestAppendUniqueMasks(t *testing.T) {
src: []string{"a", "b"}, src: []string{"a", "b"},
want: []string{"a", "b"}, want: []string{"a", "b"},
}, },
{
name: "deduplicates within src",
dst: []string{"a"},
src: []string{"b", "b", "a"},
want: []string{"a", "b"},
},
{ {
name: "empty src leaves dst unchanged", name: "empty src leaves dst unchanged",
dst: []string{"a"}, dst: []string{"a"},
@@ -103,26 +106,6 @@ func TestAppendUniqueMasks(t *testing.T) {
} }
} }
// TestAppendUniqueMasksNoExponentialGrowth reproduces the exponential growth of
// the parent's Masks slice observed with nested/repeated composite actions. A
// composite RunContext is seeded with its parent's masks and the whole seeded
// slice was previously appended back into the parent, doubling its length on
// every composite action.
func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) {
parentMasks := []string{"secret"}
for range 20 {
// compositeRC.Masks starts as a copy of the parent's masks (it is
// seeded with parent.Masks in newCompositeRunContext).
compositeMasks := make([]string, len(parentMasks))
copy(compositeMasks, parentMasks)
parentMasks = appendUniqueMasks(parentMasks, compositeMasks)
}
assert.Equal(t, []string{"secret"}, parentMasks)
}
func TestCompositeStepIfReadsJobStatusExceptInItsOwnMainSteps(t *testing.T) { func TestCompositeStepIfReadsJobStatusExceptInItsOwnMainSteps(t *testing.T) {
failStep := func(rc *RunContext) { failStep := func(rc *RunContext) {
rc.StepResults["failed"] = &model.StepResult{Conclusion: model.StepStatusFailure} rc.StepResults["failed"] = &model.StepResult{Conclusion: model.StepStatusFailure}
+186 -105
View File
@@ -8,6 +8,7 @@ import (
"context" "context"
"io" "io"
"io/fs" "io/fs"
"net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -19,6 +20,8 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git" "gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
@@ -85,6 +88,19 @@ runs:
}, },
}, },
}, },
{
name: "readLowercaseDockerfile",
step: &model.Step{},
filename: "dockerfile",
fileContent: "FROM ubuntu:20.04",
expected: &model.Action{
Name: "(Synthetic)",
Runs: model.ActionRuns{
Using: "docker",
Image: "dockerfile",
},
},
},
{ {
name: "readWithArgs", name: "readWithArgs",
step: &model.Step{ step: &model.Step{
@@ -95,6 +111,7 @@ runs:
expected: &model.Action{ expected: &model.Action{
Name: "(Synthetic)", Name: "(Synthetic)",
Inputs: map[string]model.Input{ Inputs: map[string]model.Input{
"args": {},
"cwd": { "cwd": {
Description: "(Actual working directory)", Description: "(Actual working directory)",
Required: false, Required: false,
@@ -126,7 +143,7 @@ runs:
return strings.NewReader(tt.fileContent), closerFunc(func() { closed = true }), nil return strings.NewReader(tt.fileContent), closerFunc(func() { closed = true }), nil
} }
writeFile := func(filename string, data []byte, perm fs.FileMode) error { writeFile := func(filename string, _data []byte, perm fs.FileMode) error {
assert.Equal(t, "actionDir/actionPath/trampoline.js", filename) assert.Equal(t, "actionDir/actionPath/trampoline.js", filename)
assert.Equal(t, fs.FileMode(0o644), perm) assert.Equal(t, fs.FileMode(0o644), perm)
return nil return nil
@@ -134,7 +151,7 @@ runs:
action, err := readActionImpl(t.Context(), tt.step, "actionDir", "actionPath", readFile, writeFile) action, err := readActionImpl(t.Context(), tt.step, "actionDir", "actionPath", readFile, writeFile)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
assert.Equal(t, tt.expected, action) assert.Equal(t, tt.expected, action)
assert.Equal(t, tt.filename != "", closed) assert.Equal(t, tt.filename != "", closed)
@@ -144,94 +161,48 @@ runs:
func TestActionRunner(t *testing.T) { func TestActionRunner(t *testing.T) {
t.Chdir(t.TempDir()) t.Chdir(t.TempDir())
table := []struct { for _, tt := range []struct {
name string name string
step actionStep inputs map[string]model.Input
with map[string]string
state map[string]string
expectedEnv map[string]string expectedEnv map[string]string
warning string
}{ }{
{ {
name: "with-input", name: "with-input",
step: &stepActionRemote{ inputs: map[string]model.Input{"key": {Default: "default value"}},
Step: &model.Step{
Uses: "org/repo/path@ref",
},
RunContext: &RunContext{
Config: &Config{},
Run: &model.Run{
JobID: "job",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"job": {
Name: "job",
},
},
},
},
},
action: &model.Action{
Inputs: map[string]model.Input{
"key": {
Default: "default value",
},
},
Runs: model.ActionRuns{
Using: "node16",
},
},
env: map[string]string{},
remoteAction: mustNewRemoteAction(t, "org/repo/path@ref"),
},
expectedEnv: map[string]string{"INPUT_KEY": "default value"}, expectedEnv: map[string]string{"INPUT_KEY": "default value"},
}, },
{ {
name: "restore-saved-state", name: "declared input ignores case",
step: &stepActionRemote{ inputs: map[string]model.Input{"key": {Default: "default value"}},
Step: &model.Step{ with: map[string]string{"KEY": "provided value"},
ID: "step", expectedEnv: map[string]string{"INPUT_KEY": "provided value"},
Uses: "org/repo/path@ref", },
}, {
RunContext: &RunContext{ name: "unexpected inputs warn once across stages",
ActionPath: "path", inputs: map[string]model.Input{"key": {}},
Config: &Config{}, with: map[string]string{"KEY": "provided value", "UNKNOWN": "value", "other%0A\r\n": "value"},
Run: &model.Run{ warning: "##[warning]Unexpected input(s) 'UNKNOWN', 'other%250A%0D%0A', valid inputs are ['key']",
JobID: "job", },
Workflow: &model.Workflow{ {
Jobs: map[string]*model.Job{ name: "no declared inputs",
"job": { with: map[string]string{"unknown": "value"},
Name: "job", warning: "##[warning]Unexpected input(s) 'unknown', valid inputs are ['']",
}, },
}, {
}, name: "restore-saved-state",
}, state: map[string]string{"name": "state value"},
CurrentStep: "post-step",
StepResults: map[string]*model.StepResult{
"step": {},
},
IntraActionState: map[string]map[string]string{
"step": {
"name": "state value",
},
},
},
action: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
},
},
env: map[string]string{},
remoteAction: mustNewRemoteAction(t, "org/repo/path@ref"),
},
expectedEnv: map[string]string{"STATE_name": "state value"}, expectedEnv: map[string]string{"STATE_name": "state value"},
}, },
} } {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ctx := t.Context() hook := logtest.NewWithLevel(logging.LevelWarn)
ctx := common.WithLogger(t.Context(), logging.New(hook))
cm := &containerMock{} cm := &containerMock{}
containerActionDir := "/var/run/act/actions/" + model.UsesHash("/org/repo/path@ref") + "/" containerActionDir := "/var/run/act/actions/" + model.UsesHash("/org/repo/path@ref") + "/"
cm.On("CopyDir", containerActionDir, "dir/", false, true).Return(func(ctx context.Context) error { return nil }) cm.On("CopyDir", containerActionDir, "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 {
@@ -241,14 +212,37 @@ func TestActionRunner(t *testing.T) {
} }
return true return true
}) })
cm.On("Exec", []string{"node", "--preserve-symlinks-main", containerActionDir + "path"}, envMatcher, "", "").Return(func(_ctx context.Context) error { return nil })
cm.On("Exec", nodeActionCommand(containerActionDir+"path/pre.js"), mock.Anything, "", "").Return(func(_ctx context.Context) error { return nil })
cm.On("Exec", nodeActionCommand(containerActionDir+"path/post.js"), envMatcher, "", "").Return(func(_ctx context.Context) error { return nil })
cm.On("Exec", []string{"node", "--preserve-symlinks-main", containerActionDir + "path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil }) remoteAction := mustNewRemoteAction(t, "org/repo/path@ref")
step := &stepActionRemote{
tt.step.getRunContext().JobContainer = cm Step: &model.Step{ID: "step", Uses: "org/repo/path@ref", With: tt.with},
RunContext: &RunContext{
err := runActionImpl(tt.step, "dir", mustNewRemoteAction(t, "org/repo/path@ref"))(ctx) Config: &Config{PresetGitHubContext: &model.GithubContext{}},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act IntraActionState: map[string]map[string]string{"step": tt.state},
JobContainer: cm,
},
action: &model.Action{Inputs: tt.inputs, Runs: model.ActionRuns{Using: "node16", Pre: "pre.js", Post: "post.js"}},
actionDir: "dir",
env: (&model.Step{With: tt.with}).GetEnv(),
remoteAction: remoteAction,
}
require.NoError(t, runPreStep(step)(ctx))
assert.Empty(t, hook.AllRecords())
require.NoError(t, runActionImpl(step, "dir", remoteAction)(ctx))
require.NoError(t, runPostStep(step)(ctx))
if tt.warning == "" {
assert.Empty(t, hook.AllRecords())
} else {
require.Len(t, hook.AllRecords(), 1)
assert.Equal(t, tt.warning, hook.LastRecord().Message)
rawOutput, ok := hook.LastRecord().Attr(rawOutputField)
require.True(t, ok)
assert.Equal(t, true, rawOutput)
}
cm.AssertExpectations(t) cm.AssertExpectations(t)
}) })
} }
@@ -322,7 +316,6 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "") newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
assert.Empty(t, captured.Username) assert.Empty(t, captured.Username)
assert.Empty(t, captured.Password) assert.Empty(t, captured.Password)
assert.True(t, captured.AutoRemove) assert.True(t, captured.AutoRemove)
@@ -436,7 +429,7 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
step := &stepActionRemote{ step := &stepActionRemote{
Step: &model.Step{ID: "1", Uses: "remote/action@v1", With: map[string]string{}}, Step: &model.Step{ID: "1", Uses: "remote/action@v1", With: map[string]string{}},
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{}, Config: &Config{Env: testCheckoutEnv()},
Run: &model.Run{ Run: &model.Run{
JobID: "1", JobID: "1",
Workflow: &model.Workflow{ Workflow: &model.Workflow{
@@ -474,19 +467,76 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
cancel() cancel()
releaseOnce() releaseOnce()
<-done assert.ErrorIs(t, <-done, context.Canceled)
}
func TestExecAsDockerImageChecks(t *testing.T) {
originalExists, originalBuild, originalContainer := ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer
t.Cleanup(func() {
ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer = originalExists, originalBuild, originalContainer
})
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return fakeContainer{} }
for _, testcase := range []struct {
name, platform string
exists, matches, rebuild bool
wantBuild, wantRemove bool
wantChecks []string
}{
{name: "missing image", platform: "linux/arm64", wantBuild: true, wantChecks: []string{"any"}},
{name: "unspecified platform", exists: true, matches: true, wantChecks: []string{"any"}},
{name: "any platform", platform: "any", exists: true, matches: true, wantChecks: []string{"any"}},
{name: "matching platform", platform: "linux/arm64", exists: true, matches: true, wantChecks: []string{"any", "linux/arm64"}},
{name: "different platform", platform: "linux/arm64", exists: true, wantBuild: true, wantRemove: true, wantChecks: []string{"any", "linux/arm64"}},
{name: "forced rebuild", exists: true, matches: true, rebuild: true, wantBuild: true, wantChecks: []string{"any"}},
} {
t.Run(testcase.name, func(t *testing.T) {
var checks []string
ContainerImageExistsLocally = func(_ctx context.Context, _image, platform string) (bool, error) {
checks = append(checks, platform)
return testcase.exists && (platform == "any" || testcase.matches), nil
}
var built, removed bool
ContainerNewDockerBuildExecutor = func(container.NewDockerBuildExecutorInput) common.Executor {
return func(context.Context) error { built = true; return nil }
}
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodDelete {
removed = true
_, _ = io.WriteString(writer, `[]`)
} else {
_, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
}
})
step := &stepActionRemote{
Step: &model.Step{ID: "action"},
RunContext: &RunContext{
Config: &Config{
PresetGitHubContext: &model.GithubContext{},
ContainerArchitecture: testcase.platform,
ForceRebuild: testcase.rebuild,
},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
JobContainer: &jobContainerMock{},
},
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
env: map[string]string{},
}
dir := t.TempDir()
require.NoError(t, execAsDocker(t.Context(), step, "action", dir, dir, false, stepStageMain))
assert.Equal(t, testcase.wantChecks, checks)
assert.Equal(t, testcase.wantBuild, built)
assert.Equal(t, testcase.wantRemove, removed)
})
}
} }
func TestDockerActionImageTag(t *testing.T) { func TestDockerActionImageTag(t *testing.T) {
// Remote actions already carry a unique, ref-scoped actionName (the uses
// hash), so the tag must be left untouched for backwards compatibility.
assert.Equal(t, assert.Equal(t,
"act-abc123-dockeraction:latest", "act-abc123-dockeraction:latest",
dockerActionImageTag("owner/repo", "abc123", false), dockerActionImageTag("owner/repo", "abc123", false),
) )
// Local actions keep a human-readable, repository-namespaced prefix and gain a short hash suffix that makes the tag unique per (repository, actionName).
// See https://gitea.com/gitea/runner/issues/1039.
assert.Equal(t, assert.Equal(t,
"act-owner-repo-baca2daaa2fe-dockeraction:latest", "act-owner-repo-baca2daaa2fe-dockeraction:latest",
dockerActionImageTag("owner/repo", "./", true), dockerActionImageTag("owner/repo", "./", true),
@@ -496,16 +546,11 @@ func TestDockerActionImageTag(t *testing.T) {
dockerActionImageTag("owner/repo", "./sub", true), dockerActionImageTag("owner/repo", "./sub", true),
) )
// Sanitizing every non-alphanumeric character to "-" is lossy, so distinct inputs can collapse to the same readable prefix.
// The hash suffix must keep such cases apart, otherwise an image built for one repository is reused for another.
collisions := [][2]struct { collisions := [][2]struct {
repoName string repoName string
actionName string actionName string
}{ }{
// Two different repositories, both `uses: ./`: "a/b-c" and "a-b/c" both sanitize to "a-b-c".
{{"a/b-c", "./"}, {"a-b/c", "./"}}, {{"a/b-c", "./"}, {"a-b/c", "./"}},
// A repository's root action vs another repository's sub-path action:
// "owner/repo-a" + "./" and "owner/repo" + "./a" both sanitize to "owner-repo-a".
{{"owner/repo-a", "./"}, {"owner/repo", "./a"}}, {{"owner/repo-a", "./"}, {"owner/repo", "./a"}},
} }
for _, c := range collisions { for _, c := range collisions {
@@ -517,7 +562,6 @@ func TestDockerActionImageTag(t *testing.T) {
) )
} }
// Distinct local actions within the same repository keep distinct tags.
assert.NotEqual(t, assert.NotEqual(t,
dockerActionImageTag("owner/repo", "./", true), dockerActionImageTag("owner/repo", "./", true),
dockerActionImageTag("owner/repo", "./sub", true), dockerActionImageTag("owner/repo", "./sub", true),
@@ -538,15 +582,20 @@ func TestDockerActionImageTag(t *testing.T) {
func TestExecAsDockerStageEntrypoint(t *testing.T) { func TestExecAsDockerStageEntrypoint(t *testing.T) {
orig := ContainerNewContainer orig := ContainerNewContainer
defer func() { ContainerNewContainer = orig }() defer func() { ContainerNewContainer = orig }()
fakeDockerDaemon(t, func(writer http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(writer, `{"Architecture":"x86_64"}`)
})
for _, tc := range []struct { for _, tc := range []struct {
name string name string
stage stepStage stage stepStage
with map[string]string with map[string]string
inputs map[string]model.Input
runs model.ActionRuns runs model.ActionRuns
env map[string]string env map[string]string
wantCmd []string wantCmd []string
wantEntrypoint []string wantEntrypoint []string
warning string
}{ }{
{ {
name: "main stage prefers manifest values", name: "main stage prefers manifest values",
@@ -561,17 +610,36 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
wantEntrypoint: []string{"main.sh"}, wantEntrypoint: []string{"main.sh"},
}, },
{ {
name: "main stage uses caller fallbacks", name: "main stage uses caller fallbacks in any case and warns about undeclared inputs",
stage: stepStageMain, stage: stepStageMain,
with: map[string]string{"args": "caller --flag", "entrypoint": "input.sh --verbose"}, with: map[string]string{"ARGS": "caller --flag", "entryPoint": "input.sh --verbose", "unknown": "value"},
inputs: map[string]model.Input{"message": {}},
runs: model.ActionRuns{Env: map[string]string{"ACTION_ONLY": "manifest"}}, runs: model.ActionRuns{Env: map[string]string{"ACTION_ONLY": "manifest"}},
wantCmd: []string{"caller", "--flag"}, wantCmd: []string{"caller", "--flag"},
wantEntrypoint: []string{"input.sh", "--verbose"}, wantEntrypoint: []string{"input.sh", "--verbose"},
warning: "##[warning]Unexpected input(s) 'unknown', valid inputs are ['entryPoint', 'args', 'message']",
},
{
name: "manifest args keep caller input quotes literal",
stage: stepStageMain,
with: map[string]string{"args": "it's literal"},
runs: model.ActionRuns{Args: []string{"${{ inputs.args }}"}},
env: map[string]string{"INPUT_ARGS": "it's literal"},
wantCmd: []string{"it's literal"},
},
{
name: "caller input skips its default expression",
stage: stepStageMain,
with: map[string]string{"MESSAGE": "provided"},
inputs: map[string]model.Input{"message": {Default: "${{ fromJSON('') }}"}},
runs: model.ActionRuns{Args: []string{"${{ inputs.message }}"}},
env: map[string]string{"INPUT_MESSAGE": "provided"},
wantCmd: []string{"provided"},
}, },
{ {
name: "explicit empty manifest args suppress caller args", name: "explicit empty manifest args suppress caller args",
stage: stepStageMain, stage: stepStageMain,
with: map[string]string{"args": "caller"}, with: map[string]string{"args": "it's ignored"},
runs: model.ActionRuns{Args: []string{}}, runs: model.ActionRuns{Args: []string{}},
wantCmd: []string{}, wantCmd: []string{},
}, },
@@ -597,6 +665,8 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
hook := logtest.NewWithLevel(logging.LevelWarn)
ctx := common.WithLogger(t.Context(), logging.New(hook))
tc.runs.Using, tc.runs.Image = "docker", "docker://node:14" tc.runs.Using, tc.runs.Image = "docker", "docker://node:14"
cm := &containerMock{} cm := &containerMock{}
var input *container.NewContainerInput var input *container.NewContainerInput
@@ -608,12 +678,13 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
step := &stepActionRemote{ step := &stepActionRemote{
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: tc.with}, Step: &model.Step{ID: "1", Uses: "org/action@v1", With: tc.with},
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{}, Config: &Config{PresetGitHubContext: &model.GithubContext{}},
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: tc.runs}, action: &model.Action{Inputs: tc.inputs, Runs: tc.runs},
env: mergeMaps(tc.env), env: mergeMaps(tc.env),
remoteAction: mustNewRemoteAction(t, "org/action@v1"),
} }
cm.On("Pull", false).Return(func(context.Context) error { return nil }) cm.On("Pull", false).Return(func(context.Context) error { return nil })
@@ -622,7 +693,17 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
cm.On("Start", true).Return(func(context.Context) error { return nil }) cm.On("Start", true).Return(func(context.Context) error { return nil })
cm.On("Close").Return(func(context.Context) error { return nil }) cm.On("Close").Return(func(context.Context) error { return nil })
require.NoError(t, execAsDocker(t.Context(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage)) if tc.stage == stepStageMain {
require.NoError(t, runActionImpl(step, t.TempDir(), step.remoteAction)(ctx))
} else {
require.NoError(t, execAsDocker(ctx, step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
}
if tc.warning == "" {
assert.Empty(t, hook.AllRecords())
} else {
require.Len(t, hook.AllRecords(), 1)
assert.Equal(t, tc.warning, hook.LastRecord().Message)
}
require.NotNil(t, input) require.NotNil(t, input)
assert.Equal(t, tc.wantCmd, input.Cmd) assert.Equal(t, tc.wantCmd, input.Cmd)
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint) assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
+83 -131
View File
@@ -66,145 +66,97 @@ func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
assert.Equal(t, "cancelled", rc.Run.Job().Result) assert.Equal(t, "cancelled", rc.Run.Job().Result)
} }
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does func TestMainStepsExecutorRunsAlwaysStepsAfterInterruption(t *testing.T) {
// not abandon the remaining steps when the run is cancelled mid-pipeline. The later step for name, wantError := range map[string]error{"cancel": context.Canceled, "timeout": context.DeadlineExceeded} {
// still runs (so a main-stage always() step is reached), it runs under a fresh, t.Run(name, func(t *testing.T) {
// non-cancelled context, and the job is marked cancelled. The interrupt error is still rc := createIfTestRunContext(map[string]*model.Job{
// propagated so callers up the chain see the cancellation. "job1": createJob(t, `runs-on: ubuntu-latest`, ""),
func TestMainStepsExecutorRunsAlwaysStepsAfterCancel(t *testing.T) { })
rc := createIfTestRunContext(map[string]*model.Job{ var ctx context.Context
"job1": createJob(t, `runs-on: ubuntu-latest`, ""), var interrupt func()
}) if wantError == context.Canceled {
ctx, interrupt = context.WithCancel(t.Context())
defer interrupt()
} else {
deadline := newControllableDeadlineContext(t.Context())
ctx, interrupt = deadline, deadline.expire
}
ctx, cancel := context.WithCancel(t.Context()) var ran []string
defer cancel() var laterStepCtxErr error
err := newMainStepsExecutor(rc, []common.Executor{
func(_ctx context.Context) error {
ran = append(ran, "step1")
interrupt()
return nil
},
func(ctx context.Context) error {
ran = append(ran, "always-step")
laterStepCtxErr = ctx.Err()
return nil
},
})(ctx)
var ran []string require.ErrorIs(t, err, wantError)
var laterStepCtxErr error assert.Equal(t, []string{"step1", "always-step"}, ran)
steps := []common.Executor{ require.NoError(t, laterStepCtxErr)
func(_ context.Context) error { assert.Equal(t, wantError == context.Canceled, rc.jobCancelled)
ran = append(ran, "step1") setJobResult(t.Context(), rc, rc, true)
cancel() // server cancellation lands while step1 runs if wantError == context.DeadlineExceeded {
return nil assert.True(t, rc.jobFailed)
}, assert.Equal(t, "failure", rc.getJobContext().Status)
func(c context.Context) error { assert.Equal(t, "failure", rc.Run.Job().Result)
ran = append(ran, "always-step") } else {
laterStepCtxErr = c.Err() assert.Equal(t, "cancelled", rc.Run.Job().Result)
return nil }
}, })
} }
err := newMainStepsExecutor(rc, steps)(ctx)
require.ErrorIs(t, err, context.Canceled, "interrupt error is propagated")
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after cancel")
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-cancelled context")
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
} }
// TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps guards the timeout path's symmetry with the cancel path. func TestStepsExecutorRunsMainStepsAfterPreInterruption(t *testing.T) {
// When the job deadline (timeout-minutes) lands in the gap between two steps, the job must be marked as failed (not cancelled), for name, tt := range map[string]struct {
// so always()/failure() cleanup steps run while default success() steps skip, and so the timed-out job is not reported as success. cancel bool
func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) { preErr error
rc := createIfTestRunContext(map[string]*model.Job{ wantError error
"job1": createJob(t, `runs-on: ubuntu-latest`, ""), }{
}) "cancel": {cancel: true, wantError: context.Canceled},
"failure": {preErr: assert.AnError, wantError: assert.AnError},
} {
t.Run(name, func(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
ctx := t.Context()
var cancel context.CancelFunc
if tt.cancel {
ctx, cancel = context.WithCancel(ctx)
defer cancel()
}
ctx := newControllableDeadlineContext(t.Context()) var ran []string
var mainStepCtxErr error
err := newStepsExecutor(rc, []common.Executor{
func(_ctx context.Context) error {
ran = append(ran, "pre1")
if tt.cancel {
cancel()
}
return tt.preErr
},
}, []common.Executor{
func(ctx context.Context) error {
ran = append(ran, "always-step")
mainStepCtxErr = ctx.Err()
return nil
},
})(ctx)
var ran []string require.ErrorIs(t, err, tt.wantError)
var laterStepCtxErr error assert.Equal(t, []string{"pre1", "always-step"}, ran)
steps := []common.Executor{ require.NoError(t, mainStepCtxErr)
func(context.Context) error { assert.Equal(t, tt.cancel, rc.jobCancelled)
ran = append(ran, "step1") })
ctx.expire()
return nil
},
func(c context.Context) error {
ran = append(ran, "always-step")
laterStepCtxErr = c.Err()
return nil
},
} }
err := newMainStepsExecutor(rc, steps)(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded, "the timeout error is propagated")
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after a timeout")
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-expired context")
assert.True(t, rc.jobFailed, "a job timeout marks the job failed")
assert.False(t, rc.jobCancelled, "a timeout is not a cancellation")
// The status the real main-step `if` evaluation sees: "failure", so default success() steps skip while always()/failure() steps run.
assert.Equal(t, "failure", rc.getJobContext().Status)
}
// TestStepsExecutorRunsMainStepsAfterPreCancel verifies that a cancellation landing during the
// pre phase does not abandon the main steps: newStepsExecutor still runs the main-steps executor,
// so a main-stage always()/cancelled() step is reached (under a fresh, non-cancelled context),
// the job is marked cancelled, and the cancellation is propagated. Before the fix the `.Then(...)`
// short-circuit skipped the main steps entirely when a pre step was cancelled.
func TestStepsExecutorRunsMainStepsAfterPreCancel(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
var ran []string
var mainStepCtxErr error
preSteps := []common.Executor{
func(_ context.Context) error {
ran = append(ran, "pre1")
cancel() // server cancellation lands during the pre phase
return nil
},
}
steps := []common.Executor{
func(c context.Context) error {
ran = append(ran, "always-step")
mainStepCtxErr = c.Err()
return nil
},
}
err := newStepsExecutor(rc, preSteps, steps)(ctx)
require.ErrorIs(t, err, context.Canceled, "the cancellation is propagated")
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-phase cancel")
require.NoError(t, mainStepCtxErr, "the main step runs under a fresh, non-cancelled context")
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
}
// TestStepsExecutorRunsMainStepsAfterPreFailure verifies that a failing pre step does not abandon
// the main steps: they still run (so a main-stage always()/failure() step is reached), and the
// pre-step error is propagated so the job is reported as failed. The main steps' own `if`
// evaluation is what skips success()-default steps, so running them here is safe.
func TestStepsExecutorRunsMainStepsAfterPreFailure(t *testing.T) {
rc := createIfTestRunContext(map[string]*model.Job{
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
})
var ran []string
preSteps := []common.Executor{
func(_ context.Context) error {
ran = append(ran, "pre1")
return assert.AnError
},
}
steps := []common.Executor{
func(_ context.Context) error {
ran = append(ran, "always-step")
return nil
},
}
err := newStepsExecutor(rc, preSteps, steps)(t.Context())
require.ErrorIs(t, err, assert.AnError, "the pre-step error is propagated")
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-step failure")
assert.False(t, rc.jobCancelled, "a pre-step failure is not a cancellation")
} }
// TestPreStepFailureAffectsMainStepIfStatus verifies the status path used by real // TestPreStepFailureAffectsMainStepIfStatus verifies the status path used by real
+134 -101
View File
@@ -5,61 +5,74 @@
package runner package runner
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"regexp" "maps"
"os"
"strings" "strings"
"unicode"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
) )
var commandPatternGA *regexp.Regexp // parseActionCommand parses like GitHub's runner, but rejects a bare \r GitHub would split at and keeps a value's leading '=' so masks match.
func parseActionCommand(line, resumeCommand string) (command string, properties map[string]string, data string, ok bool) {
var commandPatternADO *regexp.Regexp line = strings.TrimRight(line, "\r\n")
if strings.ContainsAny(line, "\r\n") {
func init() { return "", nil, "", false
commandPatternGA = 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) {
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 {
command = m[1]
kvPairs = parseKeyValuePairs(m[3], ",")
arg = m[4]
ok = true
} else if m := commandPatternADO.FindStringSubmatch(line); m != nil {
command = m[1]
kvPairs = parseKeyValuePairs(m[3], ";")
arg = m[4]
legacy = true
ok = true
} }
return command, kvPairs, arg, legacy, ok trimmed := strings.TrimLeftFunc(line, unicode.IsSpace)
for _, legacy := range []bool{false, true} {
header, found := strings.CutPrefix(trimmed, "::")
separator, propertySeparator, unescapeProperty, unescapeData := "::", ",", commandPropertyUnescaper.Replace, UnescapeCommandData
if legacy {
_, header, found = strings.Cut(line, "##[")
separator, propertySeparator, unescapeProperty, unescapeData = "]", ";", UnescapeLegacyCommand, UnescapeLegacyCommand
}
if !found {
continue
}
header, data, found := strings.Cut(header, separator)
if !found {
continue
}
command, propertyList, _ := strings.Cut(header, " ")
command = strings.ToLower(command)
switch command {
case "stop-commands", "set-env", "set-output", "save-state", "add-path", "add-mask", "add-matcher",
"remove-matcher", "warning", "error", "notice", "debug", "group", "endgroup", "echo":
default:
if resumeCommand == "" || !strings.EqualFold(command, resumeCommand) {
continue
}
}
if !legacy {
propertyList = strings.TrimSpace(propertyList)
}
properties := map[string]string{}
for property := range strings.SplitSeq(propertyList, propertySeparator) {
if key, value, _ := strings.Cut(strings.TrimLeft(property, "="), "="); value != "" {
properties[strings.ToLower(key)] = unescapeProperty(value)
}
}
return command, properties, unescapeData(data), true
}
return "", nil, "", false
} }
// commandHandler reports whether a line is logged, keeping per-handler command state as GitHub does.
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler { func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
logger := common.Logger(ctx) rawLogger := common.Logger(ctx).With(rawOutputField, true)
debug := rc.topLevelRunContext().Config.RunnerDebug()
echo := debug
resumeCommand := "" resumeCommand := ""
return func(line string) bool { return func(line string) bool {
command, kvPairs, arg, legacy, ok := tryParseActionCommand(line) command, kvPairs, arg, ok := parseActionCommand(line, resumeCommand)
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.
// Return true (not false) so the line still reaches the raw_output
// log handler; otherwise everything between ::stop-commands:: and
// its end token is silently dropped from the step log.
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 strings.EqualFold(command, resumeCommand) { if strings.EqualFold(command, resumeCommand) {
@@ -67,15 +80,9 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
} }
return true return true
} }
if legacy { echoed := echo
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 echoed
} }
switch command { switch command {
case "set-env": case "set-env":
@@ -83,26 +90,67 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
case "set-output": case "set-output":
rc.setOutput(ctx, kvPairs, arg) rc.setOutput(ctx, kvPairs, arg)
case "add-path": case "add-path":
rc.addPath(ctx, arg) rc.addPath(arg)
case "save-state":
rc.saveState(ctx, kvPairs, arg)
case "echo":
switch strings.ToLower(strings.TrimSpace(arg)) {
case "on":
echo = true
case "off":
echo = false
}
case "add-mask": case "add-mask":
rc.AddMask(arg) rc.AddMask(ctx, arg)
logger.Infof("%s", "***") if echoed {
// The raw line is still forwarded, carrying the secret: that is how the reporter rawLogger.Infof("::add-mask::***")
// learns the mask, and it drops the row rather than writing it out. }
return false
case "stop-commands": case "stop-commands":
resumeCommand = arg resumeCommand = arg
logger.Infof("%s", line) if len(arg) > 6 { // as on GitHub, so the log does not give away the resume token
case "save-state": rc.AddMask(ctx, arg)
logger.Infof("%s", line) }
rc.saveState(ctx, kvPairs, arg) return true
default: case "debug":
// ::debug::, ::error::, ::warning::, ::add-matcher:: and anything unrecognised are return debug
// passed through for the reporter and Gitea's web UI to render. case "notice", "warning", "error":
logger.Infof("%s", line) rawLogger.Infof("%s", formatAnnotation(command, kvPairs, arg))
return false
case "group", "endgroup":
return true
} }
return echoed
}
}
// return true to let gitea's logger handle these special outputs also // formatAnnotation folds the location and title into the message, as Gitea's web UI drops command properties.
return true func formatAnnotation(level string, properties map[string]string, message string) string {
prefix := properties["file"]
if prefix != "" {
if line := properties["line"]; line != "" {
prefix += ":" + line
if col := cmp.Or(properties["col"], properties["column"]); col != "" {
prefix += ":" + col
}
}
prefix += ": "
}
if title := properties["title"]; title != "" {
prefix += title + ": "
}
return "::" + level + "::" + EscapeCommandData(prefix+message)
}
// serviceCommandHandler applies only ::add-mask::, so a service cannot set the job's env, outputs or state.
func (rc *RunContext) serviceCommandHandler(ctx context.Context) common.LineHandler {
return func(line string) bool {
command, _, arg, ok := parseActionCommand(line, "")
if !ok || command != "add-mask" {
return true
}
rc.AddMask(ctx, arg)
return false
} }
} }
@@ -112,9 +160,18 @@ const allowUnsecureCommandsVar = "ACTIONS_ALLOW_UNSECURE_COMMANDS"
// not run, recording the error that fails the step. GitHub disabled both because a step that // not run, recording the error that fails the step. GitHub disabled both because a step that
// echoes untrusted content can use them to set NODE_OPTIONS or PATH for every later step. // echoes untrusted content can use them to set NODE_OPTIONS or PATH for every later step.
func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string) bool { func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string) bool {
if rc.allowUnsecureCommandsOptIn() { if isTruthyEnv(os.Getenv(allowUnsecureCommandsVar)) { // the runner's own opt-in wins, as on GitHub
return false return false
} }
caseInsensitive := rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive()
for _, env := range []map[string]string{rc.currentStepEnv(), rc.Env, rc.GlobalEnv} {
if value, ok := lookupFold(env, allowUnsecureCommandsVar, caseInsensitive); ok {
if isTruthyEnv(value) {
return false
}
break
}
}
// The step executor logs the failure itself, so keep this line's wording distinct. // The step executor logs the failure itself, so keep this line's wording distinct.
common.Logger(ctx).With(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(fmt.Sprintf( common.Logger(ctx).With(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(fmt.Sprintf(
@@ -130,14 +187,6 @@ func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string)
return true return true
} }
// allowUnsecureCommandsOptIn reports whether the workflow itself asked for the deprecated
// commands, from any env scope, as it can on GitHub.
func (rc *RunContext) allowUnsecureCommandsOptIn() bool {
return isTruthyEnv(rc.currentStepEnv()[allowUnsecureCommandsVar]) ||
isTruthyEnv(rc.Env[allowUnsecureCommandsVar]) ||
isTruthyEnv(rc.GlobalEnv[allowUnsecureCommandsVar])
}
// isTruthyEnv mirrors GitHub's bool.TryParse: only "true", in any casing. // isTruthyEnv mirrors GitHub's bool.TryParse: only "true", in any casing.
func isTruthyEnv(v string) bool { func isTruthyEnv(v string) bool {
return strings.EqualFold(strings.TrimSpace(v), "true") return strings.EqualFold(strings.TrimSpace(v), "true")
@@ -152,6 +201,18 @@ func (rc *RunContext) takeUnsecureCommandError() error {
return err return err
} }
// runWithCommands runs executor judging its workflow commands by env, failing it on a refused one.
func (rc *RunContext) runWithCommands(ctx context.Context, env map[string]string, executor common.Executor) error {
rc.setCurrentStepEnv(maps.Clone(env)) // the executor keeps writing to env while the command handler reads it
defer rc.setCurrentStepEnv(nil)
_ = rc.takeUnsecureCommandError() // a refusal from before belongs to no step
err := executor(ctx)
if refusal := rc.takeUnsecureCommandError(); err == nil {
return refusal
}
return err
}
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string, fromCommand bool) { 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") { if strings.EqualFold(name, "NODE_OPTIONS") {
@@ -162,7 +223,6 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
common.Logger(ctx).With(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message)) common.Logger(ctx).With(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message))
return return
} }
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)
} }
@@ -185,22 +245,18 @@ func (rc *RunContext) setEnvFile(ctx context.Context, kvPairs map[string]string,
} }
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)
stepID := rc.CurrentStep stepID := rc.CurrentStep
outputName := kvPairs["name"]
result, ok := rc.StepResults[stepID] result, ok := rc.StepResults[stepID]
if !ok { if !ok {
logger.Infof("No outputs registered for step '%s'", stepID) common.Logger(ctx).Infof("No outputs registered for step '%s'", stepID)
return return
} }
logger.Infof("::set-output:: %s=%s", outputName, arg) result.Outputs[kvPairs["name"]] = arg
result.Outputs[outputName] = arg
} }
func (rc *RunContext) addPath(ctx context.Context, arg string) { func (rc *RunContext) addPath(arg string) {
common.Logger(ctx).Infof("::add-path:: %s", arg)
extraPath := []string{arg} extraPath := []string{arg}
for _, v := range rc.ExtraPath { for _, v := range rc.ExtraPath {
if v != arg { if v != arg {
@@ -210,18 +266,6 @@ func (rc *RunContext) addPath(ctx context.Context, arg string) {
rc.ExtraPath = extraPath rc.ExtraPath = extraPath
} }
func parseKeyValuePairs(kvPairs, separator string) map[string]string {
rtn := make(map[string]string)
kvPairList := strings.SplitSeq(kvPairs, separator)
for kvPair := range kvPairList {
kv := strings.SplitN(kvPair, "=", 2)
if len(kv) == 2 {
rtn[kv[0]] = kv[1]
}
}
return rtn
}
// A Replacer never rescans what it wrote, so "%250A" stays a literal "%0A". // A Replacer never rescans what it wrote, so "%250A" stays a literal "%0A".
var ( var (
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A") commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
@@ -240,22 +284,11 @@ func UnescapeCommandData(arg string) string {
return commandDataUnescaper.Replace(arg) return commandDataUnescaper.Replace(arg)
} }
func unescapeCommandProperty(arg string) string {
return commandPropertyUnescaper.Replace(arg)
}
// UnescapeLegacyCommand decodes a "##[cmd]" line, which also spells ";" and "]" escaped. // UnescapeLegacyCommand decodes a "##[cmd]" line, which also spells ";" and "]" escaped.
func UnescapeLegacyCommand(arg string) string { func UnescapeLegacyCommand(arg string) string {
return legacyCommandUnescaper.Replace(arg) return legacyCommandUnescaper.Replace(arg)
} }
func unescapeKvPairs(kvPairs map[string]string, unescape func(string) string) map[string]string {
for k, v := range kvPairs {
kvPairs[k] = unescape(v)
}
return kvPairs
}
func (rc *RunContext) saveState(_ context.Context, kvPairs map[string]string, arg string) { func (rc *RunContext) saveState(_ context.Context, kvPairs map[string]string, arg string) {
stepID := rc.CurrentStep stepID := rc.CurrentStep
if stepID != "" { if stepID != "" {
+190 -206
View File
@@ -5,12 +5,13 @@
package runner package runner
import ( import (
"bytes"
"io" "io"
"os" "strconv"
"strings"
"testing" "testing"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest" "gitea.com/gitea/runner/act/common/logging/logtest"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
@@ -18,9 +19,55 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestParseActionCommand(t *testing.T) {
for _, test := range []struct {
name, line, resumeCommand, command string
properties map[string]string
data string
ok bool
}{
{"plain", "plain output", "", "", nil, "", false},
{"unknown v2", "::unknown::v", "", "", nil, "", false},
{"missing v2 terminator", "::set-output name=x", "", "", nil, "", false},
{"empty command", "::::", "", "", nil, "", false},
{"text before v2", "debug ::set-output name=x::v", "", "", nil, "", false},
{"missing legacy terminator", "##[debug", "", "", nil, "", false},
{"tab is not a name separator", "::set-output\tname=x::v", "", "", nil, "", false},
{"v2 leading unicode whitespace and line endings", " \t\u2003::set-output name=tab::v\r\n\r\n", "", "set-output", map[string]string{"name": "tab"}, "v", true},
{"legacy prefix and casing", "prefix ##[SET-OUTPUT NAME=mid;]v\r\n", "", "set-output", map[string]string{"name": "mid"}, "v", true},
{"only first legacy prefix", "##[unknown]##[set-output name=x]v", "", "", nil, "", false},
{"v2 delimiter boundaries", "::set-output name=a:b,other=c:d:: a::b ", "", "set-output", map[string]string{"name": "a:b", "other": "c:d"}, " a::b ", true},
{"legacy first closing bracket", "##[set-output name=x]v]tail", "", "set-output", map[string]string{"name": "x"}, "v]tail", true},
{"v2 semicolons are literal", "::set-output name=x;y=z::v", "", "set-output", map[string]string{"name": "x;y=z"}, "v", true},
{"legacy commas are literal", "##[set-output name=x,y=z]v", "", "set-output", map[string]string{"name": "x,y=z"}, "v", true},
{"values keep their leading equals", "::set-output name==x=y,=key=v,empty=,=x,spaced= =x::v", "", "set-output", map[string]string{"name": "=x=y", "key": "v", "spaced": " =x"}, "v", true},
{"v2 trims whole list only", "::set-output \tfile=x, line=2,name = value \t::v", "", "set-output", map[string]string{"file": "x", " line": "2", "name ": " value"}, "v", true},
{"legacy preserves list whitespace", "##[set-output name=q ]v", "", "set-output", map[string]string{" name": "q "}, "v", true},
{"case insensitive duplicate keys", "::SET-OUTPUT NAME=first,Name=last::v", "", "set-output", map[string]string{"name": "last"}, "v", true},
{"keys stay escaped", "::set-output na%3Ame=x::v", "", "set-output", map[string]string{"na%3ame": "x"}, "v", true},
{"v2 decode table", "::set-output name=%0D%0A%3A%2C%25%253A%252C%250A%2525%3a%2c%0d%0a%3B%5D::%0D%0A%25%3A%2C%3B%5D%250A", "", "set-output", map[string]string{"name": "\r\n:,%%3A%2C%0A%25%3a%2c%0d%0a%3B%5D"}, "\r\n%%3A%2C%3B%5D%0A", true},
{"legacy decode table", "##[set-output name=%3B%0D%0A%5D%25%253B%255D%250A%2525%3b%5d%0d%0a%3A%2C]v%5Dw%3Bx", "", "set-output", map[string]string{"name": ";\r\n]%%3B%5D%0A%25%3b%5d%0d%0a%3A%2C"}, "v]w;x", true},
{"unknown v2 falls back to legacy", "::unknown::prefix ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true},
{"unterminated v2 falls back to legacy", "::debug ##[set-output name=x]v", "", "set-output", map[string]string{"name": "x"}, "v", true},
{"v2 wins over legacy", "::debug::##[set-output name=x]v", "", "debug", map[string]string{}, "##[set-output name=x]v", true},
{"internal line breaks are not commands", "::debug::hidden\rplain\r\n", "", "", nil, "", false},
{"v2 resume token", "::ReSuMe-ToKeN::", "RESUME-TOKEN", "resume-token", map[string]string{}, "", true},
{"legacy resume token", "prefix ##[RESUME-TOKEN]v", "resume-token", "resume-token", map[string]string{}, "v", true},
{"different resume token", "::other-token::", "resume-token", "", nil, "", false},
} {
t.Run(test.name, func(t *testing.T) {
command, properties, data, ok := parseActionCommand(test.line, test.resumeCommand)
assert.Equal(t, test.command, command)
assert.Equal(t, test.properties, properties)
assert.Equal(t, test.data, data)
assert.Equal(t, test.ok, ok)
})
}
}
// unsecureRC opts into ::set-env:: and ::add-path::, which are refused without it. // unsecureRC opts into ::set-env:: and ::add-path::, which are refused without it.
func unsecureRC() *RunContext { func unsecureRC() *RunContext {
return &RunContext{Env: map[string]string{allowUnsecureCommandsVar: "true"}} return &RunContext{Config: &Config{}, Env: map[string]string{allowUnsecureCommandsVar: "true"}}
} }
func TestSetEnv(t *testing.T) { func TestSetEnv(t *testing.T) {
@@ -37,212 +84,37 @@ func TestSetEnv(t *testing.T) {
a.NotContains(rc.Env, "NODE_OPTIONS") a.NotContains(rc.Env, "NODE_OPTIONS")
a.NotContains(rc.Env, "node_options") a.NotContains(rc.Env, "node_options")
entries := hook.AllRecords() entries := hook.AllRecords()
require.Len(t, entries, 3) require.Len(t, entries, 2)
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[1].Message) a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[0].Message)
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[2].Message) a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[1].Message)
}
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
a := assert.New(t)
ctx := t.Context()
rc := unsecureRC()
handler := rc.commandHandler(ctx)
// Stop command processing until the matching end token is seen.
a.True(handler("::stop-commands::my-end-token\n"))
// A command-shaped line while stopped must not be executed (env unchanged),
// but must still return true so it reaches the raw_output log handler and is
// not dropped from the step log.
a.True(handler("::set-env name=x::valz\n"))
a.NotContains(rc.Env, "x")
// The matching end token resumes command processing.
a.True(handler("::my-end-token::\n"))
// Commands are processed again after resuming.
a.True(handler("::set-env name=y::valy\n"))
a.Equal("valy", rc.Env["y"])
} }
func TestSetOutput(t *testing.T) { func TestSetOutput(t *testing.T) {
a := assert.New(t) rc := &RunContext{Config: &Config{}, CurrentStep: "my-step", StepResults: map[string]*model.StepResult{"my-step": {Outputs: map[string]string{}}}}
ctx := t.Context() handler := rc.commandHandler(t.Context())
rc := new(RunContext)
rc.StepResults = make(map[string]*model.StepResult)
handler := rc.commandHandler(ctx)
rc.CurrentStep = "my-step" handler("::set-output NAME=modern%3A::v%0D%0A%25%3B%5D%250A\n")
rc.StepResults[rc.CurrentStep] = &model.StepResult{ handler("##[set-output name=legacy%3B%5D]v%0D%0A%25%3B%5D%250A\n")
Outputs: make(map[string]string), assert.Equal(t, map[string]string{"modern:": "v\r\n%%3B%5D%0A", "legacy;]": "v\r\n%;]%0A"}, rc.StepResults["my-step"].Outputs)
}
handler("::set-output name=x::valz\n")
a.Equal("valz", rc.StepResults["my-step"].Outputs["x"])
handler("::set-output name=x::percent2%25\n")
a.Equal("percent2%", rc.StepResults["my-step"].Outputs["x"])
handler("::set-output name=x::percent2%25%0Atest\n")
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x"])
handler("::set-output name=x::percent2%25%0Atest another3%25test\n")
a.Equal("percent2%\ntest another3%test", rc.StepResults["my-step"].Outputs["x"])
handler("::set-output name=x%3A::percent2%25%0Atest\n")
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:"])
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:"])
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) {
a := assert.New(t) for _, prefix := range []string{"::add-path::", "##[add-path]"} {
ctx := t.Context() t.Run(prefix, func(t *testing.T) {
rc := unsecureRC() rc := unsecureRC()
handler := rc.commandHandler(ctx) handler := rc.commandHandler(t.Context())
handler(prefix + "/zoo\n")
handler("::add-path::/zoo\n") handler(prefix + "/boo\n")
a.Equal("/zoo", rc.ExtraPath[0]) assert.Equal(t, []string{"/boo", "/zoo"}, rc.ExtraPath)
handler(prefix + "/zoo\n")
handler("::add-path::/boo\n") assert.Equal(t, []string{"/zoo", "/boo"}, rc.ExtraPath)
a.Equal("/boo", rc.ExtraPath[0]) })
}
func TestStopCommands(t *testing.T) {
logger, hook := logtest.NewLogger()
a := assert.New(t)
ctx := common.WithLogger(t.Context(), logger)
rc := unsecureRC()
handler := rc.commandHandler(ctx)
handler("::set-env name=x::valz\n")
a.Equal("valz", rc.Env["x"])
handler("::stop-commands::MY-END-TOKEN\n")
handler("::set-env name=x::abcd\n")
a.Equal("valz", rc.Env["x"])
handler("::my-end-token::\n")
handler("::set-env name=x::abcd\n")
a.Equal("abcd", rc.Env["x"])
messages := make([]string, 0)
for _, entry := range hook.AllRecords() {
messages = append(messages, entry.Message)
} }
a.Contains(messages, "::set-env name=x::abcd\n")
}
// The end token is arbitrary, so one that happens to name a real command must still resume
// rather than being swallowed by that command's case.
func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
a := assert.New(t)
rc := unsecureRC()
handler := rc.commandHandler(t.Context())
handler("::stop-commands::add-mask\n")
handler("::set-env name=x::suppressed\n")
a.NotContains(rc.Env, "x")
handler("::add-mask::\n")
handler("::set-env name=x::resumed\n")
a.Equal("resumed", rc.Env["x"])
}
func TestAddpathADO(t *testing.T) {
a := assert.New(t)
ctx := t.Context()
rc := unsecureRC()
handler := rc.commandHandler(ctx)
handler("##[add-path]/zoo\n")
a.Equal("/zoo", rc.ExtraPath[0])
handler("##[add-path]/boo\n")
a.Equal("/boo", rc.ExtraPath[0])
}
func TestAddmask(t *testing.T) {
logger, hook := logtest.NewLogger()
a := assert.New(t)
ctx := t.Context()
loggerCtx := common.WithLogger(ctx, logger)
rc := new(RunContext)
handler := rc.commandHandler(loggerCtx)
handler("::ADD-MASK::my::secret")
a.Equal("***", hook.LastRecord().Message)
a.Equal([]string{"my::secret"}, rc.Masks)
}
// based on https://stackoverflow.com/a/10476304
func captureOutput(t *testing.T, f func()) string {
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
f()
outC := make(chan string)
go func() {
var buf bytes.Buffer
_, err := io.Copy(&buf, r)
if err != nil {
a := assert.New(t)
a.Fail("io.Copy failed")
}
outC <- buf.String()
}()
w.Close()
os.Stdout = old
out := <-outC
return out
}
func TestAddmaskUsemask(t *testing.T) {
rc := new(RunContext)
rc.StepResults = make(map[string]*model.StepResult)
rc.CurrentStep = "my-step"
rc.StepResults[rc.CurrentStep] = &model.StepResult{
Outputs: make(map[string]string),
}
a := assert.New(t)
config := &Config{
Secrets: map[string]string{},
InsecureSecrets: false,
}
re := captureOutput(t, func() {
ctx := t.Context()
ctx = WithJobLogger(ctx, "0", "testjob", config, &rc.Masks, map[string]any{})
handler := rc.commandHandler(ctx)
handler("::add-mask::secret\n")
handler("::set-output:: token=secret\n")
})
a.Equal("[testjob] ***\n[testjob] ::set-output:: = token=***\n", re)
} }
func TestSaveState(t *testing.T) { func TestSaveState(t *testing.T) {
rc := &RunContext{ rc := &RunContext{
Config: &Config{},
CurrentStep: "step", CurrentStep: "step",
StepResults: map[string]*model.StepResult{}, StepResults: map[string]*model.StepResult{},
} }
@@ -264,31 +136,37 @@ func TestEscapeCommandData(t *testing.T) {
func TestUnsecureCommands(t *testing.T) { func TestUnsecureCommands(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
jobEnv map[string]string jobEnv map[string]string
stepEnv map[string]string globalEnv map[string]string
optedIn bool stepEnv map[string]string
processEnv string
caseInsensitive bool
optedIn bool
}{ }{
{name: "refused with no opt-in"}, {name: "refused with no opt-in"},
// GitHub reads the opt-in with bool.TryParse, so "1" is not one.
{name: "refused for a value bool.TryParse rejects", jobEnv: map[string]string{allowUnsecureCommandsVar: "1"}}, {name: "refused for a value bool.TryParse rejects", jobEnv: map[string]string{allowUnsecureCommandsVar: "1"}},
{name: "opted in through the step environment", stepEnv: map[string]string{allowUnsecureCommandsVar: "true"}, optedIn: true}, {name: "opted in through the step environment", stepEnv: map[string]string{allowUnsecureCommandsVar: "true"}, optedIn: true},
{name: "opted in through the job environment", jobEnv: map[string]string{allowUnsecureCommandsVar: "TRUE"}, optedIn: true}, {name: "opted in through the job environment", jobEnv: map[string]string{allowUnsecureCommandsVar: "TRUE"}, optedIn: true},
{name: "step opt-out overrides the job and global environment", jobEnv: map[string]string{allowUnsecureCommandsVar: "true"}, globalEnv: map[string]string{allowUnsecureCommandsVar: "true"}, stepEnv: map[string]string{allowUnsecureCommandsVar: "false"}},
{name: "the runner's own opt-in overrides a step opt-out", stepEnv: map[string]string{allowUnsecureCommandsVar: "false"}, processEnv: "true", optedIn: true},
{name: "a differently cased opt-in counts where env names are case-insensitive", jobEnv: map[string]string{"Actions_Allow_Unsecure_Commands": "true"}, caseInsensitive: true, optedIn: true},
{name: "a differently cased opt-in is ignored where env names are case-sensitive", jobEnv: map[string]string{"Actions_Allow_Unsecure_Commands": "true"}},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Setenv(allowUnsecureCommandsVar, tt.processEnv)
a := assert.New(t) a := assert.New(t)
rc := &RunContext{Env: tt.jobEnv} rc := &RunContext{Config: &Config{}, Env: tt.jobEnv, GlobalEnv: tt.globalEnv, JobContainer: fakeContainer{caseInsensitive: tt.caseInsensitive}}
rc.setCurrentStepEnv(tt.stepEnv) rc.setCurrentStepEnv(tt.stepEnv)
handler := rc.commandHandler(t.Context()) handler := rc.commandHandler(t.Context())
handler("::set-env name=x::valz\n") a.False(handler("::set-env name=x::valz\n"))
handler("::add-path::/opt/bin\n") a.False(handler("::add-path::/opt/bin\n"))
if !tt.optedIn { if !tt.optedIn {
a.Empty(rc.Env["x"]) a.Empty(rc.Env["x"])
a.Empty(rc.ExtraPath) a.Empty(rc.ExtraPath)
// The refusal fails the step that produced it, once.
require.ErrorContains(t, rc.takeUnsecureCommandError(), "set-env") require.ErrorContains(t, rc.takeUnsecureCommandError(), "set-env")
a.NoError(rc.takeUnsecureCommandError()) a.NoError(rc.takeUnsecureCommandError())
return return
@@ -299,3 +177,109 @@ func TestUnsecureCommands(t *testing.T) {
}) })
} }
} }
type maskRecorder struct {
logging.Handler
masks []string
}
func (m *maskRecorder) AddMask(mask string) { m.masks = append(m.masks, mask) }
func TestCommandLogWriter(t *testing.T) {
for _, tt := range []struct {
name string
debug bool
steps [][]string
log []string
masks []string
}{
{
name: "applied commands are hidden unless echoed",
steps: [][]string{{
"::set-env name=x::y", "::add-path::/bin", "::set-output name=a::b", "##[save-state name=a]b", "::add-matcher::m.json", "::remove-matcher owner=m::",
"::echo::On%0A",
"::set-env name=x::y", "::add-path::/bin", "::set-output name=a::b", "##[save-state name=a]b", "::add-matcher::m.json", "::remove-matcher owner=m::",
"::ADD-MASK::my::secret", "::echo::bogus", "::echo::off", "::add-mask ::empty-properties", "::unknown::v", "plain",
}},
log: []string{
"::set-env name=x::y", "::add-path::/bin", "::set-output name=a::b", "##[save-state name=a]b", "::add-matcher::m.json", "::remove-matcher owner=m::",
"::add-mask::***", "::echo::bogus", "::echo::off", "::unknown::v", "plain",
},
masks: []string{"my::secret", "empty-properties"},
},
{
name: "step debug shows debug lines and echoes commands until echo is off",
debug: true,
steps: [][]string{{"::debug::first%0Asecond", "::set-output name=a::b", "::echo::off", "::debug::again", "::set-output name=a::b"}},
log: []string{"::debug::first%0Asecond", "::set-output name=a::b", "::echo::off", "::debug::again"},
},
{
name: "debug lines are hidden without step debug and an invalid echo changes nothing",
steps: [][]string{{"::echo::bogus", "::set-output name=a::b", "::echo::on", "::debug::d"}},
},
{
name: "annotations fold their location once whether echoed or not",
steps: [][]string{{
"::echo::on",
"::error file=main.go,line=12,col=5,title=vet::undefined: x",
"::warning File=a%3Ab.go,Line=1,column=2,title=100%252C::50%25%0Anext",
"::warning file=x,line=1,col=2,column=3::m",
"::notice line=9,col=4,title=title::m",
"::error file=x,col=2::m",
"::error file=x,line=1::m",
"::notice::std::vector",
"##[error file=x%3By]m%5D",
}},
log: []string{
"::error::main.go:12:5: vet: undefined: x", "::warning::a:b.go:1:2: 100%252C: 50%25%0Anext", "::warning::x:1:2: m",
"::notice::title: m", "::error::x: m", "::error::x:1: m", "::notice::std::vector", "::error::x;y: m]",
},
},
{
name: "stop-commands keeps lines verbatim until resumed",
steps: [][]string{{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::", "::add-mask::masked"}},
log: []string{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::"},
masks: []string{"resume-token", "masked"},
},
{
name: "a short stop token naming a command stays unmasked and resumes in any case",
steps: [][]string{{"::stop-commands::NOTICE", "::notice::", "::add-mask::masked"}},
log: []string{"::stop-commands::NOTICE", "::notice::"},
masks: []string{"masked"},
},
{
name: "state does not carry over to the next step",
steps: [][]string{{"::echo::on", "::stop-commands::token77"}, {"::set-output name=a::b", "::add-mask::masked"}},
log: []string{"::stop-commands::token77"},
masks: []string{"token77", "masked"},
},
} {
t.Run(tt.name, func(t *testing.T) {
logger, hook := logtest.NewLogger()
recorder := &maskRecorder{}
ctx := common.WithLoggerHandler(common.WithLogger(t.Context(), logger), recorder)
rc := &RunContext{
Config: &Config{},
Parent: &RunContext{Config: &Config{Secrets: map[string]string{"ACTIONS_STEP_DEBUG": strconv.FormatBool(tt.debug)}}},
Env: map[string]string{allowUnsecureCommandsVar: "true"},
CurrentStep: "step",
StepResults: map[string]*model.StepResult{"step": {Outputs: map[string]string{}}},
}
for _, lines := range tt.steps {
writer := rc.commandLogWriter(ctx)
for _, line := range lines {
_, err := io.WriteString(writer, line+"\n")
require.NoError(t, err)
}
}
var log []string
for _, record := range hook.AllRecords() {
log = append(log, strings.TrimSuffix(record.Message, "\n"))
}
assert.Equal(t, tt.log, log)
assert.Equal(t, tt.masks, recorder.masks)
assert.Equal(t, tt.masks, rc.Masks)
})
}
}
+21 -25
View File
@@ -16,63 +16,59 @@ import (
var noopExecutor = func(context.Context) error { return nil } var noopExecutor = func(context.Context) error { return nil }
type containerMock struct { type linuxEnvironment struct {
mock.Mock
container.Container
container.LinuxContainerEnvironmentExtensions container.LinuxContainerEnvironmentExtensions
} }
func (linuxEnvironment) GetRunnerContext(context.Context) map[string]any {
return map[string]any{"os": "Linux", "arch": "X64", "temp": "/tmp", "tool_cache": container.DefaultToolCache}
}
type containerMock struct {
mock.Mock
container.Container
linuxEnvironment
}
func (cm *containerMock) Create(capAdd, capDrop []string) common.Executor { func (cm *containerMock) Create(capAdd, capDrop []string) common.Executor {
args := cm.Called(capAdd, capDrop) return cm.Called(capAdd, capDrop).Get(0).(func(context.Context) error)
return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) Pull(forcePull bool) common.Executor { func (cm *containerMock) Pull(forcePull bool) common.Executor {
args := cm.Called(forcePull) return cm.Called(forcePull).Get(0).(func(context.Context) error)
return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) Start(attach bool) common.Executor { func (cm *containerMock) Start(attach bool) common.Executor {
args := cm.Called(attach) return cm.Called(attach).Get(0).(func(context.Context) error)
return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) Remove() common.Executor { func (cm *containerMock) Remove() common.Executor {
args := cm.Called() return cm.Called().Get(0).(func(context.Context) error)
return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) Close() common.Executor { func (cm *containerMock) Close() common.Executor {
args := cm.Called() return cm.Called().Get(0).(func(context.Context) error)
return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
args := cm.Called(srcPath, env) return cm.Called(srcPath, env).Get(0).(func(context.Context) error)
return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) common.Executor { func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) common.Executor {
args := cm.Called(destPath, files) return cm.Called(destPath, files).Get(0).(func(context.Context) error)
return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor { func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
args := cm.Called(destPath, srcPath, useGitIgnore, skipGitDir) return cm.Called(destPath, srcPath, useGitIgnore, skipGitDir).Get(0).(func(context.Context) error)
return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) Exec(command []string, env map[string]string, user, workdir string) common.Executor { func (cm *containerMock) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
args := cm.Called(command, env, user, workdir) return cm.Called(command, env, user, workdir).Get(0).(func(context.Context) error)
return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) { func (cm *containerMock) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
args := cm.Called(ctx, srcPath) args := cm.Called(ctx, srcPath)
err, hasErr := args.Get(1).(error) err, _ := args.Get(1).(error)
if !hasErr {
err = nil
}
return args.Get(0).(io.ReadCloser), err return args.Get(0).(io.ReadCloser), err
} }
+52 -44
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"maps" "maps"
"path" "path"
@@ -49,15 +50,15 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
result := model.WorkflowCallResult{ result := model.WorkflowCallResult{
Outputs: map[string]string{}, Outputs: map[string]string{},
} }
unlock := lockJob(job)
maps.Copy(result.Outputs, job.Outputs) maps.Copy(result.Outputs, job.Outputs)
unlock()
workflowCallResult[jobName] = &result workflowCallResult[jobName] = &result
} }
} }
} }
ghc := rc.getGithubContext(ctx) ghc := rc.getGithubContext(ctx)
inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc)
ee := &exprparser.EvaluationEnvironment{ ee := &exprparser.EvaluationEnvironment{
Github: ghc, Github: ghc,
Env: env, Env: env,
@@ -65,13 +66,13 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
Jobs: &workflowCallResult, Jobs: &workflowCallResult,
// 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.StepResults,
Secrets: getWorkflowSecrets(rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: rc.Config.Vars,
Strategy: strategy, Strategy: strategy,
Matrix: rc.Matrix, Matrix: rc.Matrix,
Needs: exprparser.NeedsContext(rc.Run), Needs: exprparser.NeedsContext(rc.Run),
Inputs: inputs, Inputs: getEvaluatorInputs(rc, rc.actionInputs, ghc),
HashFiles: getHashFilesFunction(ctx, rc), HashFiles: getHashFilesFunction(ctx, rc),
} }
ee.Runner = rc.getRunnerContext(ctx) ee.Runner = rc.getRunnerContext(ctx)
@@ -103,9 +104,9 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
Github: step.getGithubContext(ctx), Github: step.getGithubContext(ctx),
Env: *step.getEnv(), Env: *step.getEnv(),
Job: jobContext, Job: jobContext,
Steps: rc.getStepsContext(), Steps: rc.StepResults,
Secrets: getWorkflowSecrets(rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: rc.Config.Vars,
Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal), Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal),
Matrix: rc.Matrix, Matrix: rc.Matrix,
Needs: exprparser.NeedsContext(rc.Run), Needs: exprparser.NeedsContext(rc.Run),
@@ -125,9 +126,9 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
} }
func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.Value) (any, error) { func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.Value) (any, error) {
hashFiles := func(v []reflect.Value) (any, error) { return func(v []reflect.Value) (any, error) {
if rc.JobContainer != nil { if rc.JobContainer != nil {
timeed, cancel := context.WithTimeout(ctx, time.Minute) timeoutCtx, cancel := context.WithTimeout(ctx, 120*time.Second)
defer cancel() defer cancel()
name := "workflow/hashfiles/index.js" name := "workflow/hashfiles/index.js"
hout := &bytes.Buffer{} hout := &bytes.Buffer{}
@@ -137,14 +138,12 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.
for i, p := range v { for i, p := range v {
s := p.String() s := p.String()
if i == 0 { if i == 0 && strings.HasPrefix(s, "--") {
if strings.HasPrefix(s, "--") { if strings.EqualFold(s, "--follow-symbolic-links") {
if strings.EqualFold(s, "--follow-symbolic-links") { followSymlink = true
followSymlink = true continue
continue
}
return "", fmt.Errorf("invalid glob option %s, available option: '--follow-symbolic-links'", s)
} }
return "", fmt.Errorf("invalid glob option %s, available option: '--follow-symbolic-links'", s)
} }
patterns = append(patterns, s) patterns = append(patterns, s)
} }
@@ -156,31 +155,40 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.
} }
stdout, stderr := rc.JobContainer.ReplaceLogWriter(hout, herr) stdout, stderr := rc.JobContainer.ReplaceLogWriter(hout, herr)
_ = rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{ defer rc.JobContainer.ReplaceLogWriter(stdout, stderr)
err := rc.JobContainer.Copy(rc.JobContainer.GetActPath(), &container.FileEntry{
Name: name, Name: name,
Mode: 0o644, Mode: 0o644,
Body: hashfiles, Body: hashfiles,
}). }).Then(rc.JobContainer.Exec([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)}, env, "", ""))(timeoutCtx)
Then(rc.JobContainer.Exec([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)}, quotedPatterns := expressionStringEscape(strings.Join(patterns, ", "))
env, "", "")). switch {
Finally(func(context.Context) error { case err == nil:
rc.JobContainer.ReplaceLogWriter(stdout, stderr) case ctx.Err() != nil:
return nil return "", ctx.Err()
})(timeed) case timeoutCtx.Err() != nil || errors.Is(err, context.DeadlineExceeded):
output := hout.String() + "\n" + herr.String() return "", fmt.Errorf("hashFiles('%s') couldn't finish within 120 seconds", quotedPatterns)
guard := "__OUTPUT__" case hashFilesNodeMissing(err):
outstart := strings.Index(output, guard) common.Logger(ctx).With(rawOutputField, true).Warnf("##[warning]hashFiles could not run because node is unavailable in the job environment. Returning an empty string.")
if outstart != -1 { return "", nil
outstart += len(guard) default:
outend := strings.Index(output[outstart:], guard) return "", fmt.Errorf("hashFiles('%s') failed. Fail to hash files under directory '%s'", quotedPatterns, rc.githubWorkspace())
if outend != -1 { }
return output[outstart : outstart+outend], nil for line := range strings.Lines(hout.String() + "\n" + herr.String()) {
if hash, ok := strings.CutPrefix(strings.TrimRight(line, "\r\n"), "__OUTPUT__"); ok {
if hash, ok = strings.CutSuffix(hash, "__OUTPUT__"); ok {
return hash, nil
}
} }
} }
} }
return "", nil return "", nil
} }
return hashFiles }
func hashFilesNodeMissing(err error) bool {
exitErr, ok := errors.AsType[container.ExitCodeError](err)
return ok && (exitErr == 126 || exitErr == 127) || err.Error() == "Cannot find: node in PATH"
} }
type expressionEvaluator struct { type expressionEvaluator struct {
@@ -189,14 +197,15 @@ type expressionEvaluator struct {
type ExpressionEvaluator = expressionEvaluator type ExpressionEvaluator = expressionEvaluator
var addMaskExpression = regexp.MustCompile(`::add-mask::.*`)
func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) { func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
logger.Debugf("evaluating expression '%s'", in) logger.Debugf("evaluating expression '%s'", in)
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck) evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
// evaluated is an any: %t renders everything but a bool as "%!t(string=...)" // evaluated is an any: %t renders everything but a bool as "%!t(string=...)"
printable := regexp.MustCompile(`::add-mask::.*`).ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)") logger.Debugf("expression '%s' evaluated to '%s'", in, addMaskExpression.ReplaceAllString(fmt.Sprintf("%v", evaluated), "::add-mask::***)"))
logger.Debugf("expression '%s' evaluated to '%s'", in, printable)
return evaluated, err return evaluated, err
} }
@@ -249,15 +258,13 @@ func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.Gi
if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow
return maps.Clone(stepInputs) return maps.Clone(stepInputs)
} }
inputs := map[string]any{}
// only a called workflow's job itself takes the inputs Gitea resolved, // only a called workflow's job itself takes the inputs Gitea resolved,
// composite actions copy its Config but have their own `inputs` // composite actions copy its Config but have their own `inputs`
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil { if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
maps.Copy(inputs, rc.Config.WorkflowCallInputs) return maps.Clone(rc.Config.WorkflowCallInputs)
return inputs
} }
inputs := map[string]any{}
maps.Copy(inputs, rc.workflowCallInputs) maps.Copy(inputs, rc.workflowCallInputs)
switch ghc.EventName { switch ghc.EventName {
@@ -325,9 +332,10 @@ func getWorkflowSecrets(rc *RunContext) map[string]string {
if rc.caller != nil { if rc.caller != nil {
return rc.workflowCallSecrets return rc.workflowCallSecrets
} }
return rc.Config.Secrets if rc.runtimeToken == "" {
} return rc.Config.Secrets
}
func getWorkflowVars(_ context.Context, rc *RunContext) map[string]string { secrets := map[string]string{"ACTIONS_RUNTIME_TOKEN": rc.runtimeToken} // as the daemon exposes its token
return rc.Config.Vars maps.Copy(secrets, rc.Config.Secrets)
return secrets
} }
+200 -110
View File
@@ -5,8 +5,20 @@
package runner package runner
import ( import (
"bytes"
"context"
"errors"
"fmt"
"io"
"reflect"
"strings" "strings"
"sync"
"testing" "testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging/logtest"
"gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
@@ -21,10 +33,11 @@ func createRunContext(t *testing.T) *RunContext {
"os": {"Linux", "Windows"}, "os": {"Linux", "Windows"},
"foo": {"bar", "baz"}, "foo": {"bar", "baz"},
}) })
assert.NoError(t, err) require.NoError(t, err)
return &RunContext{ return &RunContext{
Config: &Config{ Config: &Config{
Env: testCheckoutEnv(),
Workdir: ".", Workdir: ".",
Secrets: map[string]string{ Secrets: map[string]string{
"CASE_INSENSITIVE_SECRET": "value", "CASE_INSENSITIVE_SECRET": "value",
@@ -84,70 +97,146 @@ func TestEvaluateRunContext(t *testing.T) {
ee := rc.NewExpressionEvaluator(t.Context()) ee := rc.NewExpressionEvaluator(t.Context())
tables := []struct { tables := []struct {
in string in string
out any out any
errMesg string
}{ }{
{" 1 ", 1.0, ""}, {" 1 ", 1.0},
// {"1 + 3", "4", ""}, {"'my text'", "my text"},
// {"(1 + 3) * -2", "-8", ""}, {"contains('my text', 'te')", true},
{"'my text'", "my text", ""}, {"contains('my TEXT', 'te')", true},
{"contains('my text', 'te')", true, ""}, {"contains(fromJSON('[\"my text\"]'), 'te')", false},
{"contains('my TEXT', 'te')", true, ""}, {"contains(fromJSON('[\"foo\",\"bar\"]'), 'bar')", true},
{"contains(fromJSON('[\"my text\"]'), 'te')", false, ""}, {"startsWith('hello world', 'He')", true},
{"contains(fromJSON('[\"foo\",\"bar\"]'), 'bar')", true, ""}, {"endsWith('hello world', 'ld')", true},
{"startsWith('hello world', 'He')", true, ""}, {"format('0:{0} 2:{2} 1:{1}', 'zero', 'one', 'two')", "0:zero 2:two 1:one"},
{"endsWith('hello world', 'ld')", true, ""}, {"join(fromJSON('[\"hello\"]'),'octocat')", "hello"},
{"format('0:{0} 2:{2} 1:{1}', 'zero', 'one', 'two')", "0:zero 2:two 1:one", ""}, {"join(fromJSON('[\"hello\",\"mona\",\"the\"]'),'octocat')", "hellooctocatmonaoctocatthe"},
{"join(fromJSON('[\"hello\"]'),'octocat')", "hello", ""}, {"join('hello','mona')", "hello"},
{"join(fromJSON('[\"hello\",\"mona\",\"the\"]'),'octocat')", "hellooctocatmonaoctocatthe", ""}, {"toJSON(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}"},
{"join('hello','mona')", "hello", ""}, {"toJson(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}"},
{"toJSON(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}", ""}, {"(fromJSON('{\"foo\":\"bar\"}')).foo", "bar"},
{"toJson(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}", ""}, {"(fromJson('{\"foo\":\"bar\"}')).foo", "bar"},
{"(fromJSON('{\"foo\":\"bar\"}')).foo", "bar", ""}, {"(fromJson('[\"foo\",\"bar\"]'))[1]", "bar"},
{"(fromJson('{\"foo\":\"bar\"}')).foo", "bar", ""}, {"hashFiles('**/non-extant-files')", ""},
{"(fromJson('[\"foo\",\"bar\"]'))[1]", "bar", ""}, {"hashFiles('**/non-extant-files', '**/more-non-extant-files')", ""},
// github does return an empty string for non-existent files {"hashFiles('**/non.extant.files')", ""},
{"hashFiles('**/non-extant-files')", "", ""}, {"hashFiles('**/non''extant''files')", ""},
{"hashFiles('**/non-extant-files', '**/more-non-extant-files')", "", ""}, {"success()", true},
{"hashFiles('**/non.extant.files')", "", ""}, {"failure()", false},
{"hashFiles('**/non''extant''files')", "", ""}, {"always()", true},
{"success()", true, ""}, {"cancelled()", false},
{"failure()", false, ""}, {"github.workflow", "test-workflow"},
{"always()", true, ""}, {"github.actor", "nektos/act"},
{"cancelled()", false, ""}, {"github.run_id", "1"},
{"github.workflow", "test-workflow", ""}, {"github.run_number", "1"},
{"github.actor", "nektos/act", ""}, {"job.status", "success"},
{"github.run_id", "1", ""}, {"matrix.os", "Linux"},
{"github.run_number", "1", ""}, {"matrix.foo", "bar"},
{"job.status", "success", ""}, {"env.key", "value"},
{"matrix.os", "Linux", ""}, {"secrets.CASE_INSENSITIVE_SECRET", "value"},
{"matrix.foo", "bar", ""}, {"secrets.case_insensitive_secret", "value"},
{"env.key", "value", ""}, {"vars.CASE_INSENSITIVE_VAR", "value"},
{"secrets.CASE_INSENSITIVE_SECRET", "value", ""}, {"vars.case_insensitive_var", "value"},
{"secrets.case_insensitive_secret", "value", ""}, {"format('{{0}}', 'test')", "{0}"},
{"vars.CASE_INSENSITIVE_VAR", "value", ""}, {"format('{{{0}}}', 'test')", "{test}"},
{"vars.case_insensitive_var", "value", ""}, {"format('}}')", "}"},
{"format('{{0}}', 'test')", "{0}", ""}, {"format('echo Hello {0} ${{Test}}', 'World')", "echo Hello World ${Test}"},
{"format('{{{0}}}', 'test')", "{test}", ""}, {"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}"},
{"format('}}')", "}", ""}, {"format('echo Hello {0}{1} ${{Te{0}st}}', github.undefined_property, 'World')", "echo Hello World ${Test}"},
{"format('echo Hello {0} ${{Test}}', 'World')", "echo Hello World ${Test}", ""}, {"format('{0}', '{1}', 'World')", "{1}"},
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", ""}, {"format('{{{0}', '{1}', 'World')", "{{1}"},
{"format('echo Hello {0}{1} ${{Te{0}st}}', github.undefined_property, 'World')", "echo Hello World ${Test}", ""},
{"format('{0}', '{1}', 'World')", "{1}", ""},
{"format('{{{0}', '{1}', 'World')", "{{1}", ""},
} }
for _, table := range tables { for _, table := range tables {
t.Run(table.in, func(t *testing.T) { t.Run(table.in, func(t *testing.T) {
assertObject := assert.New(t)
out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone) out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone)
if table.errMesg == "" { require.NoError(t, err)
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act assert.Equal(t, table.out, out)
assertObject.Equal(table.out, out, table.in) })
}
}
type hashFilesContainer struct {
fakeContainer
stdout, stderr io.Writer
copyErr error
execute func([]string, map[string]string, string, string) common.Executor
}
func (c *hashFilesContainer) Copy(_destPath string, _files ...*container.FileEntry) common.Executor {
return common.NewErrorExecutor(c.copyErr)
}
func (c *hashFilesContainer) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
return c.execute(command, env, user, workdir)
}
func (c *hashFilesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
previousOut, previousErr := c.stdout, c.stderr
c.stdout, c.stderr = stdout, stderr
return previousOut, previousErr
}
func TestGetHashFilesFunction(t *testing.T) {
const failureMessage = "hashFiles('dir''file, *.sum') failed. Fail to hash files under directory '/workspace'"
for _, test := range []struct {
name string
output string
execErr error
copyErr error
hash string
err string
warning bool
expired bool
}{
{name: "success", output: "patterns __OUTPUT__wrong__OUTPUT__ echoed\n__OUTPUT__digest__OUTPUT__", hash: "digest"},
{name: "no matches", output: "__OUTPUT____OUTPUT__"},
{name: "script failure", output: "Cannot find: node in PATH\n__OUTPUT__digest__OUTPUT__", execErr: container.ExitCodeError(1), err: failureMessage},
{name: "timeout", execErr: context.DeadlineExceeded, err: "hashFiles('dir''file, *.sum') couldn't finish within 120 seconds"},
{name: "job deadline", execErr: context.DeadlineExceeded, expired: true, err: context.DeadlineExceeded.Error()},
{name: "node missing", execErr: fmt.Errorf("wrapped: %w", container.ExitCodeError(127)), warning: true},
{name: "node not executable", execErr: container.ExitCodeError(126), warning: true},
{name: "host node missing", execErr: errors.New("Cannot find: node in PATH"), warning: true},
{name: "copy failure", copyErr: errors.New("open /runner/cache: permission denied"), err: failureMessage},
} {
t.Run(test.name, func(t *testing.T) {
logger, hook := logtest.NewLogger()
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
jobContainer := &hashFilesContainer{stdout: stdout, stderr: stderr, copyErr: test.copyErr}
jobContainer.execute = func(command []string, env map[string]string, user, workdir string) common.Executor {
assert.Equal(t, []string{"node", "/var/run/act/workflow/hashfiles/index.js"}, command)
assert.Equal(t, map[string]string{"patterns": "dir'file\n*.sum", "followSymbolicLinks": "true"}, env)
assert.Empty(t, user)
assert.Empty(t, workdir)
return func(_ctx context.Context) error {
_, err := io.WriteString(jobContainer.stderr, test.output)
require.NoError(t, err)
return test.execErr
}
}
ctx := common.WithLogger(t.Context(), logger)
if test.expired {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, time.Now())
defer cancel()
}
result, err := getHashFilesFunction(ctx, &RunContext{
Config: &Config{Workdir: "/workspace"},
JobContainer: jobContainer,
})([]reflect.Value{reflect.ValueOf("--follow-symbolic-links"), reflect.ValueOf("dir'file"), reflect.ValueOf("*.sum")})
if test.err == "" {
require.NoError(t, err)
} else { } else {
assertObject.Error(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act require.EqualError(t, err, test.err)
assertObject.Equal(table.errMesg, err.Error(), table.in) }
assert.Equal(t, test.hash, result)
assert.Same(t, stdout, jobContainer.stdout)
assert.Same(t, stderr, jobContainer.stderr)
if test.warning {
require.Len(t, hook.AllRecords(), 1)
assert.Equal(t, "##[warning]hashFiles could not run because node is unavailable in the job environment. Returning an empty string.", hook.LastRecord().Message)
} else {
assert.Empty(t, hook.AllRecords())
} }
}) })
} }
@@ -164,33 +253,26 @@ func TestEvaluateStep(t *testing.T) {
ee := rc.NewStepExpressionEvaluator(t.Context(), step) ee := rc.NewStepExpressionEvaluator(t.Context(), step)
tables := []struct { tables := []struct {
in string in string
out any out any
errMesg string
}{ }{
{"steps.idwithnothing.conclusion", model.StepStatusSuccess.String(), ""}, {"steps.idwithnothing.conclusion", model.StepStatusSuccess.String()},
{"steps.idwithnothing.outcome", model.StepStatusFailure.String(), ""}, {"steps.idwithnothing.outcome", model.StepStatusFailure.String()},
{"steps.idwithnothing.outputs.foowithnothing", "barwithnothing", ""}, {"steps.idwithnothing.outputs.foowithnothing", "barwithnothing"},
{"steps.id-with-hyphens.conclusion", model.StepStatusSuccess.String(), ""}, {"steps.id-with-hyphens.conclusion", model.StepStatusSuccess.String()},
{"steps.id-with-hyphens.outcome", model.StepStatusFailure.String(), ""}, {"steps.id-with-hyphens.outcome", model.StepStatusFailure.String()},
{"steps.id-with-hyphens.outputs.foo-with-hyphens", "bar-with-hyphens", ""}, {"steps.id-with-hyphens.outputs.foo-with-hyphens", "bar-with-hyphens"},
{"steps.id_with_underscores.conclusion", model.StepStatusSuccess.String(), ""}, {"steps.id_with_underscores.conclusion", model.StepStatusSuccess.String()},
{"steps.id_with_underscores.outcome", model.StepStatusFailure.String(), ""}, {"steps.id_with_underscores.outcome", model.StepStatusFailure.String()},
{"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores", ""}, {"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores"},
{"inputs.forged", nil, ""}, // INPUT_* env is not an input {"inputs.forged", nil},
} }
for _, table := range tables { for _, table := range tables {
t.Run(table.in, func(t *testing.T) { t.Run(table.in, func(t *testing.T) {
assertObject := assert.New(t)
out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone) out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone)
if table.errMesg == "" { require.NoError(t, err)
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act assert.Equal(t, table.out, out)
assertObject.Equal(table.out, out, table.in)
} else {
assertObject.Error(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
assertObject.Equal(table.errMesg, err.Error(), table.in)
}
}) })
} }
} }
@@ -198,6 +280,7 @@ func TestEvaluateStep(t *testing.T) {
func TestInterpolate(t *testing.T) { func TestInterpolate(t *testing.T) {
rc := &RunContext{ rc := &RunContext{
Config: &Config{ Config: &Config{
Env: testCheckoutEnv(),
Workdir: ".", Workdir: ".",
Secrets: map[string]string{ Secrets: map[string]string{
"CASE_INSENSITIVE_SECRET": "value", "CASE_INSENSITIVE_SECRET": "value",
@@ -282,10 +365,8 @@ func TestInterpolate(t *testing.T) {
} }
func TestGetEvaluatorInputsBoolean(t *testing.T) { func TestGetEvaluatorInputsBoolean(t *testing.T) {
workflows := map[string]string{ workflow := func(event string) string {
"workflow_call": ` return "on:\n " + event + `:
on:
workflow_call:
inputs: inputs:
flag: flag:
type: boolean type: boolean
@@ -295,20 +376,7 @@ on:
default: gitea default: gitea
unset: unset:
type: boolean type: boolean
`, `
"workflow_dispatch": `
on:
workflow_dispatch:
inputs:
flag:
type: boolean
default: true
name:
type: string
default: gitea
unset:
type: boolean
`,
} }
tables := []struct { tables := []struct {
@@ -317,7 +385,6 @@ on:
flag any flag any
}{ }{
{ {
// Gitea >= 1.27 resolves the inputs server-side and sends native JSON types
name: "native bool true", name: "native bool true",
event: map[string]any{"inputs": map[string]any{"flag": true}}, event: map[string]any{"inputs": map[string]any{"flag": true}},
flag: true, flag: true,
@@ -344,10 +411,10 @@ on:
}, },
} }
for eventName, workflow := range workflows { for _, eventName := range []string{"workflow_call", "workflow_dispatch"} {
for _, table := range tables { for _, table := range tables {
t.Run(eventName+"/"+table.name, func(t *testing.T) { t.Run(eventName+"/"+table.name, func(t *testing.T) {
wf, err := model.ReadWorkflow(strings.NewReader(workflow)) wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName)))
require.NoError(t, err) require.NoError(t, err)
rc := &RunContext{ rc := &RunContext{
@@ -365,7 +432,7 @@ on:
} }
t.Run("deferred workflow call inputs", func(t *testing.T) { t.Run("deferred workflow call inputs", func(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(workflows["workflow_call"] + ` workflow, err := model.ReadWorkflow(strings.NewReader(workflow("workflow_call") + `
jobs: jobs:
test: {} test: {}
call: call:
@@ -379,9 +446,9 @@ jobs:
for _, value := range []string{"true", "false"} { for _, value := range []string{"true", "false"} {
t.Run(value, func(t *testing.T) { t.Run(value, func(t *testing.T) {
t.Parallel() t.Parallel()
parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"${{ 'runner' }}"}`}) parent, err := (&runnerImpl{config: &Config{PresetGitHubContext: &model.GithubContext{}}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"${{ 'runner' }}"}`})
require.NoError(t, err) require.NoError(t, err)
child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil) child, err := (&runnerImpl{config: &Config{PresetGitHubContext: &model.GithubContext{}}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, map[string]any{"flag": value == "true", "name": "${{ 'runner' }}", "unset": false}, child.workflowCallInputs) assert.Equal(t, map[string]any{"flag": value == "true", "name": "${{ 'runner' }}", "unset": false}, child.workflowCallInputs)
assert.Equal(t, rawWith, job.RawWith) assert.Equal(t, rawWith, job.RawWith)
@@ -405,7 +472,6 @@ on:
config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}} config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}}
ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}} ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}}
// the event's own inputs must not override those Gitea resolved
job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}} job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}}
assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc)) assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc))
@@ -438,7 +504,7 @@ jobs:
assert.Equal(t, "workflow", env["INPUT_FALLBACK"]) assert.Equal(t, "workflow", env["INPUT_FALLBACK"])
} }
func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) { func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsAndRuntimeToken(t *testing.T) {
callerWorkflow, err := model.ReadWorkflow(strings.NewReader(` callerWorkflow, err := model.ReadWorkflow(strings.NewReader(`
jobs: jobs:
call: call:
@@ -461,7 +527,11 @@ jobs:
require.NoError(t, err) require.NoError(t, err)
notCallable, err := model.ReadWorkflow(strings.NewReader("on: push\njobs:\n test: {}\n")) notCallable, err := model.ReadWorkflow(strings.NewReader("on: push\njobs:\n test: {}\n"))
require.NoError(t, err) require.NoError(t, err)
config := &Config{Secrets: map[string]string{"token": "${{ github.token }}"}} config := &Config{
Secrets: map[string]string{"token": "${{ github.token }}"},
PresetGitHubContext: &model.GithubContext{},
RuntimeTokens: map[*model.Workflow]string{callerWorkflow: "caller-run"},
}
resolve := func(callerJobID string, workflow *model.Workflow) *RunContext { resolve := func(callerJobID string, workflow *model.Workflow) *RunContext {
parent, err := (&runnerImpl{config: config}).newRunContext(t.Context(), &model.Run{Workflow: callerWorkflow, JobID: callerJobID}, nil) parent, err := (&runnerImpl{config: config}).newRunContext(t.Context(), &model.Run{Workflow: callerWorkflow, JobID: callerJobID}, nil)
require.NoError(t, err) require.NoError(t, err)
@@ -473,9 +543,10 @@ jobs:
called := resolve("call", callee) called := resolve("call", callee)
assert.Equal(t, map[string]any{"flag": true}, called.workflowCallInputs) assert.Equal(t, map[string]any{"flag": true}, called.workflowCallInputs)
assert.Equal(t, map[string]string{"undeclared": "${{ github.token }}"}, called.workflowCallSecrets) assert.Equal(t, map[string]string{"undeclared": "${{ github.token }}"}, called.workflowCallSecrets)
assert.Equal(t, "caller-run", called.GetEnv()["ACTIONS_RUNTIME_TOKEN"])
inherited := resolve("inherit", notCallable) inherited := resolve("inherit", notCallable)
assert.Empty(t, inherited.workflowCallInputs) assert.Empty(t, inherited.workflowCallInputs)
assert.Equal(t, config.Secrets, inherited.workflowCallSecrets) assert.Equal(t, map[string]string{"token": "${{ github.token }}", "ACTIONS_RUNTIME_TOKEN": "caller-run"}, inherited.workflowCallSecrets)
} }
func TestJobNameMasksSecrets(t *testing.T) { func TestJobNameMasksSecrets(t *testing.T) {
@@ -488,7 +559,7 @@ jobs:
`)) `))
require.NoError(t, err) require.NoError(t, err)
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"}, PresetGitHubContext: &model.GithubContext{}}}
containerName := func(jobID string) string { containerName := func(jobID string) string {
rc, err := 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) require.NoError(t, err)
@@ -497,6 +568,25 @@ jobs:
} }
a, b := containerName("a"), containerName("b") a, b := containerName("a"), containerName("b")
assert.NotContains(t, a, "s3cr3t") // it reaches the container name, which no log masker covers assert.NotContains(t, a, "s3cr3t")
assert.NotEqual(t, a, b) // masking the name must not collapse two jobs onto one container assert.NotEqual(t, a, b)
}
func TestNewExpressionEvaluatorReadsJobOutputsWhileCombinationsWriteThem(t *testing.T) {
rc := createRunContext(t)
rc.caller = &caller{runContext: createRunContext(t)}
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
job := rc.Run.Job()
var writes sync.WaitGroup
writes.Go(func() {
for range 100 {
unlock := lockJob(job)
job.Outputs = map[string]string{"out": "value"}
unlock()
}
})
for range 100 {
rc.NewExpressionEvaluator(t.Context())
}
writes.Wait()
} }
+4
View File
@@ -60,3 +60,7 @@ func requireHostTools(t *testing.T, tools ...string) {
} }
} }
} }
func testCheckoutEnv() map[string]string {
return map[string]string{"GITHUB_REPOSITORY": "owner/repo", "GITHUB_REF": "refs/heads/main", "SHA_REF": "1234567890"}
}
+45 -33
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"cmp"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json/v2" "encoding/json/v2"
@@ -19,10 +20,12 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/exprparser"
@@ -95,6 +98,39 @@ func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Exec
rawLogger := common.Logger(ctx).With(rawOutputField, true) rawLogger := common.Logger(ctx).With(rawOutputField, true)
rawLogger.Infof("Prepare all required actions") rawLogger.Infof("Prepare all required actions")
fetchCtx, cancelFetches := context.WithCancel(ctx)
var fetches sync.WaitGroup
defer func() { cancelFetches(); fetches.Wait() }()
slots := make(chan struct{}, 4)
job := rc.topLevelRunContext()
if job.actionDownloads == nil {
job.actionDownloads = map[string]*actionDownload{}
}
for _, preparer := range preparers {
action, ok := preparer.(*stepActionRemote)
if !ok || strings.Contains(action.Step.Uses, "${{") || action.action != nil {
continue
}
github := action.getGithubContext(common.WithLogger(ctx, logging.New(logging.DiscardHandler)))
remote, err := newRemoteAction(action.Step.Uses, github, action.RunContext.Config.GitHubInstance)
if err != nil || action.skipsLocalCheckout(remote, github) {
continue
}
key := remote.downloadKey(action.RunContext.Config.DefaultActionURL())
if job.actionDownloads[key] != nil {
continue
}
download := &actionDownload{fetched: make(chan struct{})}
job.actionDownloads[key] = download
fetches.Go(func() {
defer close(download.fetched)
defer common.RecoverPanic(&download.err)
slots <- struct{}{}
defer func() { <-slots }()
download.dir, download.sha, download.err = action.fetchAction(fetchCtx, remote, github.Token)
})
}
for _, preparer := range preparers { for _, preparer := range preparers {
if err := preparer.prepareActionExecutor()(ctx); err != nil { if err := preparer.prepareActionExecutor()(ctx); err != nil {
// No step has run yet, so the failure belongs to the job. // No step has run yet, so the failure belongs to the job.
@@ -120,10 +156,7 @@ func (e setupError) Unwrap() error { return e.error }
func printCompleteJobName(rc *RunContext) common.Executor { func printCompleteJobName(rc *RunContext) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
// Name holds a matrix combination; JobName is the shared name GitHub reports. // Name holds a matrix combination; JobName is the shared name GitHub reports.
name := rc.JobName name := cmp.Or(rc.JobName, rc.Name)
if name == "" {
name = rc.Name
}
if name == "" && rc.Run != nil { if name == "" && rc.Run != nil {
name = rc.Run.JobID name = rc.Run.JobID
} }
@@ -252,7 +285,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
jobError := common.JobError(ctx) jobError := common.JobError(ctx)
var err error var err error
// always allow 1 min for stopping and removing the runner, even if we were cancelled // always allow 1 min for stopping and removing the runner, even if we were cancelled
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), jobCleanupTimeout) ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
defer cancel() defer cancel()
logger := common.Logger(ctx) logger := common.Logger(ctx)
@@ -323,10 +356,7 @@ func newStepsExecutor(rc *RunContext, preSteps, steps []common.Executor) common.
return preErr // as on GitHub, no step runs after the job setup failed return preErr // as on GitHub, no step runs after the job setup failed
} }
mainErr := mainExecutor(ctx) mainErr := mainExecutor(ctx)
if preErr != nil { return cmp.Or(preErr, mainErr)
return preErr
}
return mainErr
} }
} }
@@ -370,6 +400,7 @@ func runMainStepsAfterInterrupt(ctx context.Context, rc *RunContext, steps []com
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) { func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
success = success && !rc.jobFailed
// Matrix combinations share one *model.Job and run in parallel; serialize the // Matrix combinations share one *model.Job and run in parallel; serialize the
// read-modify-write of the job result so a failing combination is not lost-updated by a // read-modify-write of the job result so a failing combination is not lost-updated by a
@@ -512,8 +543,7 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
ctx, cancel := context.WithTimeout(ctx, jobSummaryUploadPhaseTimeout) ctx, cancel := context.WithTimeout(ctx, jobSummaryUploadPhaseTimeout)
defer cancel() defer cancel()
env := rc.GetEnv() env := rc.GetEnv()
caps := strings.TrimSpace(env["GITEA_ACTIONS_CAPABILITIES"]) if !hasJobSummaryCapability(env["GITEA_ACTIONS_CAPABILITIES"]) {
if !hasJobSummaryCapability(caps) {
// Server did not advertise support. Do not attempt upload. // Server did not advertise support. Do not attempt upload.
return return
} }
@@ -646,19 +676,18 @@ func readSingleFileFromContainerArchive(ctx context.Context, env container.Execu
} }
defer rc.Close() defer rc.Close()
requestedPath := path.Clean(strings.TrimPrefix(p, "/"))
tr := tar.NewReader(rc) tr := tar.NewReader(rc)
for { for {
header, err := tr.Next() header, err := tr.Next()
if err == io.EOF {
return nil, false
}
if err != nil { if err != nil {
return nil, false return nil, false
} }
if header.Typeflag != tar.TypeReg { if header.Typeflag != tar.TypeReg {
continue continue
} }
if !archiveEntryMatchesPath(header.Name, p) { entryName := path.Clean(strings.TrimPrefix(header.Name, "/"))
if entryName != requestedPath && entryName != path.Base(requestedPath) {
continue continue
} }
// Summaries larger than the limit are truncated rather than dropped, so the // Summaries larger than the limit are truncated rather than dropped, so the
@@ -681,12 +710,6 @@ func readSingleFileFromContainerArchive(ctx context.Context, env container.Execu
} }
} }
func archiveEntryMatchesPath(entryName, requestedPath string) bool {
entryName = path.Clean(strings.TrimPrefix(entryName, "/"))
requestedPath = path.Clean(strings.TrimPrefix(requestedPath, "/"))
return entryName == requestedPath || entryName == path.Base(requestedPath)
}
// useJobStageLogger tags the log entries of a job level executor with the stage they belong to. // useJobStageLogger tags the log entries of a job level executor with the stage they belong to.
func useJobStageLogger(stage string, executor common.Executor) common.Executor { func useJobStageLogger(stage string, executor common.Executor) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
@@ -697,17 +720,6 @@ func useJobStageLogger(stage string, executor common.Executor) common.Executor {
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.InterpolateName(ctx, stepModel.String()), stage.String()) ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String())
return rc.withCommandLogWriter(ctx, executor)
logWriter := rc.commandLogWriter(ctx)
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
// Flush any buffered, not-yet-newline-terminated trailing line once the
// step has finished, so the final line of the step's output is not lost
// when it is not newline-terminated.
defer common.FlushWriter(logWriter)
return executor(ctx)
} }
} }
+210 -267
View File
@@ -14,13 +14,17 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"testing/synctest"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest" "gitea.com/gitea/runner/act/common/logging/logtest"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
@@ -98,7 +102,7 @@ func (jim *jobInfoMock) result(result string) {
type jobContainerMock struct { type jobContainerMock struct {
container.Container container.Container
container.LinuxContainerEnvironmentExtensions linuxEnvironment
} }
func (jcm *jobContainerMock) ReplaceLogWriter(_, _ io.Writer) (io.Writer, io.Writer) { func (jcm *jobContainerMock) ReplaceLogWriter(_, _ io.Writer) (io.Writer, io.Writer) {
@@ -118,14 +122,13 @@ func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, er
type actionPreparerMock struct { type actionPreparerMock struct {
reference string reference string
sha string sha string
err error
prepared int prepared int
} }
func (apm *actionPreparerMock) prepareActionExecutor() common.Executor { func (apm *actionPreparerMock) prepareActionExecutor() common.Executor {
return func(context.Context) error { return func(context.Context) error {
apm.prepared++ apm.prepared++
return apm.err return nil
} }
} }
@@ -133,24 +136,85 @@ func (apm *actionPreparerMock) actionDownloadInfo() (string, string) {
return apm.reference, apm.sha return apm.reference, apm.sha
} }
func TestPrintPrepareActionsGolden(t *testing.T) { func TestPrintPrepareActionsFetchesConcurrently(t *testing.T) {
buf := &bytes.Buffer{} synctest.Test(t, func(t *testing.T) {
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo)) buf := &bytes.Buffer{}
ctx := common.WithLogger(t.Context(), logger.With("job", "j1")) ctx := common.WithLogger(t.Context(), logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo)).With("job", "j1"))
rc := newPrefetchRunContext(t)
started, release := make(chan string), make(chan struct{})
var fetched atomic.Int32
setFetch(t, func(_ctx context.Context, input git.NewGitCloneExecutorInput) (string, string, error) {
fetched.Add(1)
started <- input.Ref
<-release
return filepath.Join(input.Dir, "commit"), "sha-" + input.Ref, nil
})
preparers := append(newRemotePreparers(rc, "org/repo@v1", "org/repo/sub@v1", "org/repo@v2", "org/repo@v3", "org/repo@v4", "org/repo@v5"), &actionPreparerMock{})
finished := make(chan error)
go func() { finished <- printPrepareActions(rc, preparers)(ctx) }()
for range 4 {
<-started
}
synctest.Wait()
assert.EqualValues(t, 4, fetched.Load())
close(release)
<-started
require.NoError(t, <-finished)
assert.EqualValues(t, 5, fetched.Load())
lines := []string{"[j1] | Prepare all required actions"}
for _, ref := range []string{"v1", "v2", "v3", "v4", "v5"} {
lines = append(lines, fmt.Sprintf("[j1] | Download action repository 'org/repo@%s' (SHA:sha-%s)", ref, ref))
}
assert.Equal(t, strings.Join(append(lines, ""), "\n"), buf.String())
})
}
preparers := []actionPreparer{ func TestPrintPrepareActionsFailsJobAndCancelsLaterDownloads(t *testing.T) {
&actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"}, for name, panicFetch := range map[string]bool{"error": false, "panic": true} {
// A step that downloads nothing, such as the checkout of the workflow's own repository. t.Run(name, func(t *testing.T) {
&actionPreparerMock{}, synctest.Test(t, func(t *testing.T) {
rc := newPrefetchRunContext(t)
setFetch(t, func(ctx context.Context, input git.NewGitCloneExecutorInput) (string, string, error) {
if input.Ref == "v1" {
if panicFetch {
panic("broken archive")
}
return "", "", assert.AnError
}
<-ctx.Done()
return "", "", ctx.Err()
})
ctx := common.WithJobErrorContainer(common.WithLogger(t.Context(), logging.New(logging.DiscardHandler)))
remaining := &actionPreparerMock{}
err := printPrepareActions(rc, append(newRemotePreparers(rc, "org/repo@v1", "org/repo@v2"), remaining))(ctx)
if panicFetch {
require.ErrorContains(t, err, "panic: broken archive")
} else {
require.ErrorIs(t, err, assert.AnError)
}
require.ErrorIs(t, err, common.JobError(ctx))
assert.True(t, rc.jobFailed)
assert.Zero(t, remaining.prepared)
})
})
} }
require.NoError(t, printPrepareActions(&RunContext{}, preparers)(ctx)) }
want := strings.Join([]string{ func newPrefetchRunContext(t *testing.T) *RunContext {
"[j1] | Prepare all required actions", return &RunContext{
"[j1] | Download action repository 'actions/checkout@v7' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)", Config: &Config{ActionCacheDir: t.TempDir(), PresetGitHubContext: &model.GithubContext{}},
"", Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
}, "\n") }
assert.Equal(t, want, buf.String()) }
func newRemotePreparers(rc *RunContext, uses ...string) []actionPreparer {
preparers := make([]actionPreparer, 0, len(uses))
for _, use := range uses {
preparers = append(preparers, &stepActionRemote{Step: &model.Step{Uses: use}, RunContext: rc, readAction: func(_ctx context.Context, _step *model.Step, _actionDir, _actionPath string, _readFile actionYamlReader, _writeFile fileWriter) (*model.Action, error) {
return &model.Action{}, nil
}})
}
return preparers
} }
func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) { func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
@@ -163,26 +227,6 @@ func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
assert.Empty(t, buf.String()) assert.Empty(t, buf.String())
} }
func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
logger := logging.New(logtest.New())
ctx := common.WithJobErrorContainer(common.WithLogger(t.Context(), logger.With("job", "j1")))
downloadErr := errors.New("failed to fetch \"actions/checkout\"")
rc := &RunContext{}
remaining := &actionPreparerMock{reference: "actions/setup-go@v6", sha: "sha"}
err := printPrepareActions(rc, []actionPreparer{
&actionPreparerMock{err: downloadErr},
remaining,
})(ctx)
require.ErrorIs(t, err, downloadErr)
// No step has run yet, so the failure has to be recorded against the job itself.
assert.Equal(t, downloadErr, common.JobError(ctx))
assert.True(t, rc.jobFailed)
assert.Zero(t, remaining.prepared)
}
func TestNewStepsExecutorSkipsStepsAfterSetupFailure(t *testing.T) { func TestNewStepsExecutorSkipsStepsAfterSetupFailure(t *testing.T) {
for name, tt := range map[string]struct { for name, tt := range map[string]struct {
preErr error preErr error
@@ -246,7 +290,7 @@ func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
Jobs: map[string]*model.Job{"test": {}}, Jobs: map[string]*model.Job{"test": {}},
}, },
}, },
Config: &Config{}, Config: &Config{PresetGitHubContext: &model.GithubContext{}},
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
@@ -320,6 +364,7 @@ func TestNewJobExecutor(t *testing.T) {
output string output string
startError error startError error
cancelOnStart bool cancelOnStart bool
timeout bool
}{ }{
{ {
name: "zeroSteps", name: "zeroSteps",
@@ -470,17 +515,28 @@ func TestNewJobExecutor(t *testing.T) {
result: "cancelled", result: "cancelled",
cancelOnStart: true, cancelOnStart: true,
}, },
} {
name: "post steps run after timeout",
contains := func(needle string, haystack []string) bool { steps: []*model.Step{{ID: "1"}},
return slices.Contains(haystack, needle) preSteps: []bool{false},
postSteps: []bool{true},
executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"},
result: "failure",
timeout: true,
},
} }
for _, tt := range table { for _, tt := range table {
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 ctx := common.WithJobErrorContainer(t.Context())
cancel := func() {}
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(t.Context())) var expire func()
if tt.timeout {
deadline := newControllableDeadlineContext(ctx)
ctx, expire = deadline, deadline.expire
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel() defer cancel()
jim := &jobInfoMock{} jim := &jobInfoMock{}
sfm := &stepFactoryMock{} sfm := &stepFactoryMock{}
@@ -494,7 +550,7 @@ func TestNewJobExecutor(t *testing.T) {
}, },
}, },
}, },
Config: &Config{}, Config: &Config{PresetGitHubContext: &model.GithubContext{}},
} }
if tt.output != "" { if tt.output != "" {
require.NoError(t, rc.Run.Job().RawOutputs.Encode(map[string]string{"bad": tt.output})) require.NoError(t, rc.Run.Job().RawOutputs.Encode(map[string]string{"bad": tt.output}))
@@ -519,7 +575,7 @@ func TestNewJobExecutor(t *testing.T) {
sfm.On("newStep", stepModel, rc).Return(sm, nil) sfm.On("newStep", stepModel, rc).Return(sm, nil)
sm.On("pre").Return(func(ctx context.Context) error { sm.On("pre").Return(func(_ctx context.Context) error {
if tt.preSteps[i] { if tt.preSteps[i] {
executorOrder = append(executorOrder, "pre"+stepModel.ID) executorOrder = append(executorOrder, "pre"+stepModel.ID)
} }
@@ -528,6 +584,10 @@ func TestNewJobExecutor(t *testing.T) {
sm.On("main").Return(func(ctx context.Context) error { sm.On("main").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "step"+stepModel.ID) executorOrder = append(executorOrder, "step"+stepModel.ID)
if tt.timeout {
expire()
return ctx.Err()
}
if tt.hasError { if tt.hasError {
return errors.New("error") return errors.New("error")
} }
@@ -538,6 +598,9 @@ func TestNewJobExecutor(t *testing.T) {
if tt.postSteps[i] { if tt.postSteps[i] {
executorOrder = append(executorOrder, "post"+stepModel.ID) executorOrder = append(executorOrder, "post"+stepModel.ID)
} }
if tt.timeout {
require.NoError(t, ctx.Err(), "post step must run against a fresh, non-expired context")
}
return nil return nil
}) })
@@ -555,7 +618,7 @@ func TestNewJobExecutor(t *testing.T) {
return nil return nil
}) })
if contains("stopContainer", tt.executedSteps) { if slices.Contains(tt.executedSteps, "stopContainer") {
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()) require.NoError(t, ctx.Err())
@@ -569,7 +632,7 @@ func TestNewJobExecutor(t *testing.T) {
} }
if len(tt.steps) > 0 { 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
}) })
@@ -590,8 +653,6 @@ func TestNewJobExecutor(t *testing.T) {
jim.AssertExpectations(t) jim.AssertExpectations(t)
sfm.AssertExpectations(t) sfm.AssertExpectations(t)
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
}) })
} }
} }
@@ -622,123 +683,32 @@ func (ctx *controllableDeadlineContext) expire() {
close(ctx.done) close(ctx.done)
} }
// TestNewJobExecutorRunsPostStepsAfterTimeout guards the timeout-minutes cleanup
// path: when a job exceeds its timeout the job context is DeadlineExceeded, but
// the post steps (cleanup hooks like actions/checkout post and cache save) must
// still run against a fresh, non-expired context, and the job must still be
// reported as failed.
func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
ctx := newControllableDeadlineContext(common.WithJobErrorContainer(t.Context()))
jim := &jobInfoMock{}
sfm := &stepFactoryMock{}
rc := &RunContext{
JobContainer: &jobContainerMock{},
Run: &model.Run{
JobID: "test",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"test": {},
},
},
},
Config: &Config{},
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
stepModel := &model.Step{ID: "1"}
jim.On("steps").Return([]*model.Step{stepModel})
jim.On("matrix").Return(map[string]any{})
jim.On("startContainer").Return(func(ctx context.Context) error { return nil })
jim.On("interpolateOutputs").Return(func(ctx context.Context) error { return nil })
jim.On("closeContainer").Return(func(ctx context.Context) error { return nil })
// The job timed out, so it must be reported as failed and still cleaned up.
jim.On("stopContainer").Return(func(context.Context) error { return nil })
jim.On("result", "failure")
sm := &stepMock{}
sfm.On("newStep", stepModel, rc).Return(sm, nil)
sm.On("pre").Return(func(ctx context.Context) error { return nil })
sm.On("main").Return(func(stepCtx context.Context) error {
ctx.expire()
return stepCtx.Err()
})
var postRan bool
var postCtxErr error
sm.On("post").Return(func(ctx context.Context) error {
postRan = true
postCtxErr = ctx.Err()
return nil
})
executor := newJobExecutor(jim, sfm, rc)
// The executor itself returns nil on timeout: the failure is surfaced through
// the job result ("failure", asserted via the result mock below), not the
// return value.
require.NoError(t, executor(ctx))
assert.True(t, postRan, "post step must run after a job timeout")
require.NoError(t, postCtxErr, "post step must run against a fresh, non-expired context")
jim.AssertExpectations(t)
sfm.AssertExpectations(t)
sm.AssertExpectations(t)
}
// TestSetJobResultMatrixContinueOnError exercises the parallel-matrix path
// end-to-end: two combinations share one *model.Job and continue-on-error is
// keyed on matrix.experimental, so one combination tolerates its failure and the
// other does not. The job is reported as continue-on-error only when EVERY failing
// combination was tolerated; a single firm failure makes the whole job firm, and
// handleFailure then fails the run.
func TestSetJobResultMatrixContinueOnError(t *testing.T) { func TestSetJobResultMatrixContinueOnError(t *testing.T) {
const jobYAML = "continue-on-error: ${{ matrix.experimental }}\nruns-on: ubuntu-latest"
newSharedJob := func(t *testing.T) (*model.Job, *model.Workflow) {
t.Helper()
var job *model.Job
require.NoError(t, yaml.Unmarshal([]byte(jobYAML), &job))
return job, &model.Workflow{
Name: "workflow1",
Jobs: map[string]*model.Job{"job1": job},
}
}
planFor := func(wf *model.Workflow) *model.Plan {
return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: wf, JobID: "job1"}}}}}
}
ctx := t.Context() ctx := t.Context()
for name, tolerated := range map[string]bool{
"one tolerated and one firm failure fails the run": false,
"all tolerated failures do not fail the run": true,
} {
t.Run(name, func(t *testing.T) {
var job *model.Job
require.NoError(t, yaml.Unmarshal([]byte("continue-on-error: ${{ matrix.experimental }}\nruns-on: ubuntu-latest"), &job))
workflow := &model.Workflow{Name: "workflow1", Jobs: map[string]*model.Job{"job1": job}}
for _, experimental := range []bool{true, tolerated} {
rc := newTestRC(workflow, map[string]any{"experimental": experimental})
rc.Config.PresetGitHubContext = &model.GithubContext{}
setJobResult(ctx, rc, rc, false)
}
// fail drives a single matrix combination through the failure path; each assert.Equal(t, "failure", job.Result)
// RunContext is its own jobInfo (rc implements jobInfo) and shares the job. assert.Equal(t, tolerated, job.ContinueOnError)
fail := func(wf *model.Workflow, experimental bool) { err := handleFailure(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "job1"}}}}})(ctx)
rc := newTestRC(wf, map[string]any{"experimental": experimental}) if tolerated {
setJobResult(ctx, rc, rc, false) require.NoError(t, err)
} else {
require.Error(t, err)
}
})
} }
t.Run("one tolerated and one firm failure fails the run", func(t *testing.T) {
job, wf := newSharedJob(t)
// Order is intentional: the tolerated combination finishes first, then the
// firm one. The firm-failure latch must still win regardless of order.
fail(wf, true)
fail(wf, false)
assert.Equal(t, "failure", job.Result)
assert.False(t, job.ContinueOnError, "a single firm failure must make the whole job firm")
assert.Error(t, handleFailure(planFor(wf))(ctx))
})
t.Run("all tolerated failures do not fail the run", func(t *testing.T) {
job, wf := newSharedJob(t)
fail(wf, true)
fail(wf, true)
assert.Equal(t, "failure", job.Result)
assert.True(t, job.ContinueOnError, "every failing combination was tolerated")
assert.NoError(t, handleFailure(planFor(wf))(ctx))
})
} }
func TestHasJobSummaryCapability(t *testing.T) { func TestHasJobSummaryCapability(t *testing.T) {
@@ -925,44 +895,34 @@ func TestTryUploadJobSummaryUploadsEachStepIndependentlyWithInstanceTransport(t
cm.AssertExpectations(t) cm.AssertExpectations(t)
} }
func TestTryUploadJobSummaryRequiresExactCapability(t *testing.T) { func TestTryUploadJobSummarySkipsUnsupportedUploads(t *testing.T) {
requests := 0 for name, tt := range map[string]struct {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capabilities string
requests++ runtimeToken string
w.WriteHeader(http.StatusNoContent) }{
})) "requires exact capability": {"not-job-summary,job-summary-v2", fakeRuntimeToken(34)},
defer server.Close() "requires job ID": {"job-summary", "not-a-jwt"},
} {
t.Run(name, func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _r *http.Request) {
requests++
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
rc := newJobSummaryRC(map[string]string{ rc := newJobSummaryRC(map[string]string{
"GITEA_ACTIONS_CAPABILITIES": "not-job-summary,job-summary-v2", "GITEA_ACTIONS_CAPABILITIES": tt.capabilities,
"ACTIONS_RUNTIME_URL": server.URL, "ACTIONS_RUNTIME_URL": server.URL,
"ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34), "ACTIONS_RUNTIME_TOKEN": tt.runtimeToken,
"GITEA_RUN_ID": "12", "GITEA_RUN_ID": "12",
}, &containerMock{}, 1) }, &containerMock{}, 1)
tryUploadJobSummary(t.Context(), rc) tryUploadJobSummary(t.Context(), rc)
assert.Equal(t, 0, requests) assert.Zero(t, requests)
} })
}
func TestTryUploadJobSummarySkipsWhenJobIDMissingFromToken(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
rc := newJobSummaryRC(map[string]string{
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
"ACTIONS_RUNTIME_URL": server.URL,
"ACTIONS_RUNTIME_TOKEN": "not-a-jwt",
"GITEA_RUN_ID": "12",
}, &containerMock{}, 1)
tryUploadJobSummary(t.Context(), rc)
assert.Equal(t, 0, requests)
} }
func TestExtractJobIDFromRuntimeToken(t *testing.T) { func TestExtractJobIDFromRuntimeToken(t *testing.T) {
@@ -991,47 +951,39 @@ func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T
cm.AssertExpectations(t) cm.AssertExpectations(t)
} }
func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) { func TestReadSingleFileFromContainerArchiveSizeLimit(t *testing.T) {
logger, hook := logtest.NewLogger() for name, tt := range map[string]struct {
ctx := common.WithLogger(t.Context(), logger) content string
cm := &containerMock{} maxBytes int64
content := strings.Repeat("a", 300) want string
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return( }{
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: content}))), "truncates oversized summary": {strings.Repeat("a", 300), 200, strings.Repeat("a", 200-len(jobSummaryTruncationMarker)) + jobSummaryTruncationMarker},
nil, "keeps exact limit": {"abc", 3, "abc"},
).Once() } {
t.Run(name, func(t *testing.T) {
logger, hook := logtest.NewLogger()
ctx := common.WithLogger(t.Context(), logger)
cm := &containerMock{}
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: tt.content}))),
nil,
).Once()
const maxBytes = 200 body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", tt.maxBytes)
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", maxBytes)
// Oversized summaries are truncated to the limit (reserving room for the marker) assert.True(t, ok)
// rather than dropped entirely, and the truncation marker is appended. assert.Equal(t, []byte(tt.want), body)
assert.True(t, ok) assert.LessOrEqual(t, int64(len(body)), tt.maxBytes)
assert.LessOrEqual(t, len(body), maxBytes) if int64(len(tt.content)) > tt.maxBytes {
keep := maxBytes - len(jobSummaryTruncationMarker) if assert.Len(t, hook.AllRecords(), 1) {
assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body) assert.Contains(t, hook.LastRecord().Message, "job summary truncated")
if assert.Len(t, hook.AllRecords(), 1) { }
assert.Contains(t, hook.AllRecords()[0].Message, "job summary truncated") } else {
assert.Empty(t, hook.AllRecords())
}
cm.AssertExpectations(t)
})
} }
cm.AssertExpectations(t)
}
func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) {
logger, hook := logtest.NewLogger()
ctx := common.WithLogger(t.Context(), logger)
cm := &containerMock{}
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: "abc"}))),
nil,
).Once()
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 3)
// A summary that is exactly at the limit is kept whole and not flagged as truncated.
assert.True(t, ok)
assert.Equal(t, []byte("abc"), body)
assert.Empty(t, hook.AllRecords())
cm.AssertExpectations(t)
} }
type tarEntry struct { type tarEntry struct {
@@ -1072,7 +1024,7 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
func newTestRC(wf *model.Workflow, matrix map[string]any) *RunContext { func newTestRC(wf *model.Workflow, matrix map[string]any) *RunContext {
return &RunContext{ return &RunContext{
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }}, Config: &Config{Workdir: ".", Env: testCheckoutEnv(), PlatformPicker: func([]string) string { return "ubuntu-latest" }},
StepResults: map[string]*model.StepResult{}, StepResults: map[string]*model.StepResult{},
Env: map[string]string{}, Env: map[string]string{},
Matrix: matrix, Matrix: matrix,
@@ -1088,6 +1040,7 @@ func makeTestRC(t *testing.T, jobYAML string) *RunContext {
Name: "workflow1", Name: "workflow1",
Jobs: map[string]*model.Job{"job1": job}, Jobs: map[string]*model.Job{"job1": job},
}, nil) }, nil)
rc.Config.PresetGitHubContext = &model.GithubContext{}
rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
return rc return rc
} }
@@ -1137,34 +1090,24 @@ func TestEvaluateJobContinueOnError(t *testing.T) {
} }
func TestJobSetContinueOnError(t *testing.T) { func TestJobSetContinueOnError(t *testing.T) {
t.Run("first call true", func(t *testing.T) { for name, tt := range map[string]struct {
j := &model.Job{} values []bool
j.SetContinueOnError(true) want bool
assert.True(t, j.ContinueOnError) }{
}) "first call true": {[]bool{true}, true},
t.Run("first call false", func(t *testing.T) { "first call false": {[]bool{false}, false},
j := &model.Job{} "true then false locks to false": {[]bool{true, false}, false},
j.SetContinueOnError(false) "false then true stays false": {[]bool{false, true}, false},
assert.False(t, j.ContinueOnError) "true then true stays true": {[]bool{true, true}, true},
}) } {
t.Run("true then false locks to false", func(t *testing.T) { t.Run(name, func(t *testing.T) {
j := &model.Job{} job := &model.Job{}
j.SetContinueOnError(true) for _, value := range tt.values {
j.SetContinueOnError(false) job.SetContinueOnError(value)
assert.False(t, j.ContinueOnError) }
}) assert.Equal(t, tt.want, job.ContinueOnError)
t.Run("false then true stays false", func(t *testing.T) { })
j := &model.Job{} }
j.SetContinueOnError(false)
j.SetContinueOnError(true)
assert.False(t, j.ContinueOnError)
})
t.Run("true then true stays true", func(t *testing.T) {
j := &model.Job{}
j.SetContinueOnError(true)
j.SetContinueOnError(true)
assert.True(t, j.ContinueOnError)
})
} }
func TestTryUploadJobSummaryMasksSecrets(t *testing.T) { func TestTryUploadJobSummaryMasksSecrets(t *testing.T) {
+11 -8
View File
@@ -40,15 +40,18 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
if hookPath == "" { if hookPath == "" {
return nil return nil
} }
previousStep := rc.CurrentStep
rc.CurrentStep = ""
defer func() { rc.CurrentStep = previousStep }()
cmd, shell := hookCommand(hookPath) cmd, shell := hookCommand(hookPath)
rawLogger := common.Logger(ctx).With(rawOutputField, true) rawLogger := common.Logger(ctx).With(rawOutputField, true)
defer rawLogger.Infof("::endgroup::")
rawLogger.Infof("::group::Run '%s'", EscapeCommandData(hookPath))
rawLogger.Infof("A %s hook has been configured by the runner administrator", name) rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
rawLogger.Infof("::group::Run '%s'", EscapeCommandData(hookPath))
if shell != "" { if shell != "" {
rawLogger.Infof("shell: %s", shell) rawLogger.Infof("shell: %s", shell)
} }
rawLogger.Infof("::endgroup::")
env := mergeMaps(rc.containerSpec.Env, rc.GetEnv()) env := mergeMaps(rc.containerSpec.Env, rc.GetEnv())
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env) rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
@@ -56,12 +59,12 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
err := rc.setupHookFileCommands(ctx, env) err := rc.setupHookFileCommands(ctx, env)
if err == nil { if err == nil {
err = rc.JobContainer.Exec(cmd, env, "", "")(ctx) err = rc.runWithCommands(ctx, env, func(ctx context.Context) error {
} return rc.withCommandLogWriter(ctx, rc.JobContainer.Exec(cmd, env, "", ""))
// Processed even on failure, so a hook that exports what it managed to set up before })
// failing still hands it to the job. if processErr := rc.processHookFileCommands(ctx); err == nil { // a failed hook still exports what it set up
if processErr := rc.processHookFileCommands(ctx); err == nil { err = processErr
err = processErr }
} }
if err == nil { if err == nil {
return nil return nil
+96 -3
View File
@@ -14,6 +14,7 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/common/logging/logtest" "gitea.com/gitea/runner/act/common/logging/logtest"
"gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -27,8 +28,11 @@ type hookContainer struct {
cmd []string cmd []string
env map[string]string env map[string]string
err error err error
copyErr error
envFile map[string]string envFile map[string]string
pathTar []byte pathTar []byte
output string
stdout io.Writer
} }
func (c *hookContainer) ToContainerPath(path string) string { return path } func (c *hookContainer) ToContainerPath(path string) string { return path }
@@ -41,10 +45,25 @@ func (c *hookContainer) GetRunnerContext(context.Context) map[string]any {
func (c *hookContainer) Exec(command []string, env map[string]string, _, _ string) common.Executor { func (c *hookContainer) Exec(command []string, env map[string]string, _, _ string) common.Executor {
return func(context.Context) error { return func(context.Context) error {
c.cmd, c.env = command, env c.cmd, c.env = command, env
if c.output != "" {
if _, err := io.WriteString(c.stdout, c.output); err != nil {
return err
}
}
return c.err return c.err
} }
} }
func (c *hookContainer) ReplaceLogWriter(stdout, _ io.Writer) (io.Writer, io.Writer) {
old := c.stdout
c.stdout = stdout
return old, nil
}
func (c *hookContainer) Copy(_destPath string, _files ...*container.FileEntry) common.Executor {
return common.NewErrorExecutor(c.copyErr)
}
func (c *hookContainer) UpdateFromEnv(_ string, env *map[string]string) common.Executor { func (c *hookContainer) UpdateFromEnv(_ string, env *map[string]string) common.Executor {
return func(context.Context) error { return func(context.Context) error {
maps.Copy(*env, c.envFile) maps.Copy(*env, c.envFile)
@@ -59,7 +78,7 @@ func (c *hookContainer) GetContainerArchive(context.Context, string) (io.ReadClo
// newHookRunContext returns a RunContext and the context to run a hook with, whose logger is // newHookRunContext returns a RunContext and the context to run a hook with, whose logger is
// silenced so the hook's job-log output does not reach the test output. // silenced so the hook's job-log output does not reach the test output.
func newHookRunContext(jobContainer *hookContainer, config *Config) (*RunContext, context.Context) { func newHookRunContext(jobContainer *hookContainer, config *Config) (*RunContext, context.Context) {
// Env is left nil so that it is built from the config, as it is for a real job. config.PresetGitHubContext = &model.GithubContext{}
rc := &RunContext{ rc := &RunContext{
Config: config, Config: config,
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
@@ -72,15 +91,29 @@ func newHookRunContext(jobContainer *hookContainer, config *Config) (*RunContext
} }
func TestRunJobHook(t *testing.T) { func TestRunJobHook(t *testing.T) {
t.Run("runs the hook with the job environment", func(t *testing.T) { t.Run("runs the hook with the job environment after its closed header group", func(t *testing.T) {
jobContainer := &hookContainer{} jobContainer := &hookContainer{output: "from the hook\n"}
rc, ctx := newHookRunContext(jobContainer, &Config{ rc, ctx := newHookRunContext(jobContainer, &Config{
JobStartedHook: "/hooks/started.sh", JobStartedHook: "/hooks/started.sh",
Env: map[string]string{"A_VAR": "value", jobStartedHookEnv: "/from/env.sh"}, Env: map[string]string{"A_VAR": "value", jobStartedHookEnv: "/from/env.sh"},
}) })
records := logtest.New()
ctx = common.WithLogger(ctx, logging.New(records))
require.NoError(t, rc.runJobStartedHook(ctx)) require.NoError(t, rc.runJobStartedHook(ctx))
var lines []string
for _, record := range records.AllRecords() {
lines = append(lines, record.Message)
}
assert.Equal(t, []string{
"A job started hook has been configured by the runner administrator",
"::group::Run '/hooks/started.sh'",
"shell: bash -e {0}",
"::endgroup::",
"from the hook\n",
}, lines)
// The setting wins over the environment variable. // The setting wins over the environment variable.
assert.Equal(t, []string{"bash", "-e", "/hooks/started.sh"}, jobContainer.cmd) assert.Equal(t, []string{"bash", "-e", "/hooks/started.sh"}, jobContainer.cmd)
assert.Equal(t, "value", jobContainer.env["A_VAR"]) assert.Equal(t, "value", jobContainer.env["A_VAR"])
@@ -90,6 +123,57 @@ func TestRunJobHook(t *testing.T) {
assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"]) assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"])
}) })
t.Run("each hook has its own command state", func(t *testing.T) {
jobContainer := &hookContainer{output: "::stop-commands::token77"}
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/started.sh", JobCompletedHook: "/completed.sh"})
jobWriter := rc.commandLogWriter(ctx)
jobContainer.stdout = jobWriter
require.NoError(t, rc.runJobStartedHook(ctx))
jobContainer.output = "::add-mask::s3cret"
require.NoError(t, rc.runJobCompletedHook(ctx))
assert.Equal(t, []string{"token77", "s3cret"}, rc.Masks)
assert.Same(t, jobWriter, jobContainer.stdout)
})
t.Run("fails on refused commands and honors the container opt-in", func(t *testing.T) {
for _, allow := range []bool{false, true} {
jobContainer := &hookContainer{output: "::set-env name=FROM_HOOK::1\n::add-path::/opt/hook/bin"}
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/started.sh"})
if allow {
rc.containerSpec.Env = map[string]string{allowUnsecureCommandsVar: "true"}
}
err := rc.runJobStartedHook(ctx)
if allow {
require.NoError(t, err)
assert.Equal(t, "1", rc.Env["FROM_HOOK"])
assert.Equal(t, []string{"/opt/hook/bin"}, rc.ExtraPath)
} else {
require.ErrorContains(t, err, "workflow command is disabled")
assert.Equal(t, "failure", rc.getJobContext().Status)
require.ErrorContains(t, common.JobError(ctx), "workflow command is disabled")
assert.NotContains(t, rc.Env, "FROM_HOOK")
assert.Empty(t, rc.ExtraPath)
}
assert.Nil(t, rc.currentStepEnv())
require.NoError(t, rc.takeUnsecureCommandError())
}
})
t.Run("the completed hook has no preceding step identity", func(t *testing.T) {
jobContainer := &hookContainer{output: "::set-output name=value::after\n::save-state name=value::after"}
rc, ctx := newHookRunContext(jobContainer, &Config{JobCompletedHook: "/completed.sh"})
rc.CurrentStep = "last-step"
rc.StepResults = map[string]*model.StepResult{"last-step": {Outputs: map[string]string{"value": "before"}}}
require.NoError(t, rc.runJobCompletedHook(ctx))
assert.Empty(t, jobContainer.env["GITHUB_ACTION"])
assert.Equal(t, "before", rc.StepResults["last-step"].Outputs["value"])
assert.Empty(t, rc.IntraActionState)
assert.Equal(t, "last-step", rc.CurrentStep)
})
// Each hook reads its own variable, so a swapped constant cannot pass. // Each hook reads its own variable, so a swapped constant cannot pass.
t.Run("falls back to the GitHub environment variables", func(t *testing.T) { t.Run("falls back to the GitHub environment variables", func(t *testing.T) {
for name, hook := range map[string]struct { for name, hook := range map[string]struct {
@@ -135,6 +219,15 @@ func TestRunJobHook(t *testing.T) {
require.ErrorContains(t, common.JobError(ctx), "boom") require.ErrorContains(t, common.JobError(ctx), "boom")
}) })
t.Run("a hook whose file commands cannot be reset neither runs nor exports stale ones", func(t *testing.T) {
jobContainer := &hookContainer{copyErr: errors.New("boom"), envFile: map[string]string{"STALE": "1"}}
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/hooks/started.sh"})
require.ErrorContains(t, rc.runJobStartedHook(ctx), "boom")
assert.Nil(t, jobContainer.cmd)
assert.NotContains(t, rc.Env, "STALE")
})
t.Run("is a no-op without a hook", func(t *testing.T) { t.Run("is a no-op without a hook", func(t *testing.T) {
jobContainer := &hookContainer{} jobContainer := &hookContainer{}
rc, ctx := newHookRunContext(jobContainer, &Config{}) rc, ctx := newHookRunContext(jobContainer, &Config{})
+15 -30
View File
@@ -51,29 +51,21 @@ const (
) )
var ( var (
colors []int colors = []int{blue, yellow, green, magenta, red, gray, cyan}
nextColor int nextColor int
mux sync.Mutex mux sync.Mutex
) )
func init() {
nextColor = 0
colors = []int{
blue, yellow, green, magenta, red, gray, cyan,
}
}
type masksContextKey string type masksContextKey string
const masksContextKeyVal = masksContextKey("logrus.FieldLogger") const masksContextKeyVal = masksContextKey("logrus.FieldLogger")
type valueMaskerContextKey struct{}
// Logger returns the appropriate logger for current context // Logger returns the appropriate logger for current context
func Masks(ctx context.Context) *[]string { func Masks(ctx context.Context) *[]string {
val := ctx.Value(masksContextKeyVal) if masks, ok := ctx.Value(masksContextKeyVal).(*[]string); ok {
if val != nil { return masks
if masks, ok := val.(*[]string); ok {
return masks
}
} }
return &[]string{} return &[]string{}
} }
@@ -98,6 +90,8 @@ func WithJobLoggerFactory(ctx context.Context, factory JobLoggerFactory) context
// WithJobLogger attaches a new logger to context that is aware of steps // WithJobLogger attaches a new logger to context that is aware of steps
func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, masks *[]string, matrix map[string]any) context.Context { func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, masks *[]string, matrix map[string]any) context.Context {
masker := valueMasker(config.InsecureSecrets, config.maskers())
ctx = context.WithValue(ctx, valueMaskerContextKey{}, masker)
ctx = WithMasks(ctx, masks) ctx = WithMasks(ctx, masks)
var output logging.Handler var output logging.Handler
@@ -119,7 +113,7 @@ func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, m
} }
handlers = append(handlers, &maskedHandler{ handlers = append(handlers, &maskedHandler{
Handler: output, Handler: output,
masker: valueMasker(config.InsecureSecrets, config.maskers()), masker: masker,
}) })
level := logging.LevelTrace level := logging.LevelTrace
@@ -150,6 +144,9 @@ func WithCompositeStepLogger(ctx context.Context, stepID string) context.Context
} }
func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stageName string) context.Context { func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stageName string) context.Context {
if masker, ok := ctx.Value(valueMaskerContextKey{}).(entryProcessor); ok {
stepName = masker(ctx, logging.Record{Message: stepName}).Message
}
stepIDs := []string{stepID} stepIDs := []string{stepID}
ctx = common.WithStepIDs(ctx, stepIDs) ctx = common.WithStepIDs(ctx, stepIDs)
@@ -178,7 +175,7 @@ var secretValueEncoders = []func(string) string{
uriDataEscape, uriDataEscape,
url.QueryEscape, // the form-encoded twin of uriDataEscape, which spells a space "+" url.QueryEscape, // the form-encoded twin of uriDataEscape, which spells a space "+"
url.PathEscape, url.PathEscape,
xmlDataEscape, xmlDataEscaper.Replace,
trimDoubleQuotes, trimDoubleQuotes,
} }
@@ -225,10 +222,6 @@ var xmlDataEscaper = strings.NewReplacer(
"'", "&apos;", "'", "&apos;",
) )
func xmlDataEscape(v string) string {
return xmlDataEscaper.Replace(v)
}
func trimDoubleQuotes(v string) string { func trimDoubleQuotes(v string) string {
if len(v) > 8 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) { if len(v) > 8 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) {
return v[1 : len(v)-1] return v[1 : len(v)-1]
@@ -407,7 +400,7 @@ func (f textFormatter) format(r logging.Record) ([]byte, error) {
b := &bytes.Buffer{} b := &bytes.Buffer{}
// the web renderer decodes command data, so this local view has to as well // the web renderer decodes command data, so this local view has to as well
if _, _, _, ok := tryParseRawActionCommand(r.Message + "\n"); ok { if _, _, _, ok := parseActionCommand(r.Message, ""); ok && (strings.HasPrefix(r.Message, "::") || strings.HasPrefix(r.Message, "##[")) {
r.Message = UnescapeCommandData(r.Message) r.Message = UnescapeCommandData(r.Message)
} }
r.Message = strings.TrimSuffix(r.Message, "\n") r.Message = strings.TrimSuffix(r.Message, "\n")
@@ -468,7 +461,8 @@ func (f textFormatter) print(b *bytes.Buffer, r logging.Record) {
} }
func (f textFormatter) isColored() bool { func (f textFormatter) isColored() bool {
isColored := checkIfTerminal(f.w) file, ok := f.w.(*os.File)
isColored := ok && term.IsTerminal(int(file.Fd()))
if force, ok := os.LookupEnv("CLICOLOR_FORCE"); ok && force != "0" { if force, ok := os.LookupEnv("CLICOLOR_FORCE"); ok && force != "0" {
isColored = true isColored = true
@@ -481,15 +475,6 @@ func (f textFormatter) isColored() bool {
return isColored return isColored
} }
func checkIfTerminal(w io.Writer) bool {
switch v := w.(type) {
case *os.File:
return term.IsTerminal(int(v.Fd()))
default:
return false
}
}
// maskSecrets hides this job's secrets in a value that reaches somewhere the log maskers cannot, // maskSecrets hides this job's secrets in a value that reaches somewhere the log maskers cannot,
// such as a container name or a job summary. Masks added at runtime count, so a summary written // such as a container name or a job summary. Masks added at runtime count, so a summary written
// after ::add-mask:: is covered too. // after ::add-mask:: is covered too.
+13 -1
View File
@@ -255,7 +255,11 @@ func TestWithJobLoggerFansOutAndMasksOnlyOutput(t *testing.T) {
func TestJobLogJSONHandler(t *testing.T) { func TestJobLogJSONHandler(t *testing.T) {
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
logger := logging.New(newJobLogJSONHandler(buf)).With("job", "build", "msg", "clash").WithError(errors.New("boom")) rc := &RunContext{Config: &Config{Secrets: map[string]string{"TOKEN": "s3cr3t-value"}}}
ctx := WithJobLoggerFactory(t.Context(), factoryFunc(func() logging.Handler { return newJobLogJSONHandler(buf) }))
ctx = WithJobLogger(ctx, "j1", "build", rc.Config, &rc.Masks, nil)
logger := common.Logger(withStepLogger(ctx, 1, "s1", "deploy s3cr3t-value", StageMain)).
With("msg", "clash").WithError(errors.New("boom"))
logger.Warnf("careful %d", 1) logger.Warnf("careful %d", 1)
var got map[string]any var got map[string]any
@@ -263,7 +267,15 @@ func TestJobLogJSONHandler(t *testing.T) {
assert.Equal(t, "careful 1", got["msg"]) assert.Equal(t, "careful 1", got["msg"])
assert.Equal(t, "warning", got["level"]) assert.Equal(t, "warning", got["level"])
assert.Equal(t, "build", got["job"]) assert.Equal(t, "build", got["job"])
assert.Equal(t, "deploy ***", got["step"])
assert.Equal(t, "boom", got["error"]) assert.Equal(t, "boom", got["error"])
assert.Equal(t, "clash", got["fields.msg"]) assert.Equal(t, "clash", got["fields.msg"])
assert.NotEmpty(t, got["time"]) assert.NotEmpty(t, got["time"])
rc.AddMask(ctx, "runtime-value")
buf.Reset()
common.Logger(withStepLogger(ctx, 2, "s2", "deploy s3cr3t-value runtime-value", StageMain)).Info("done")
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, "deploy *** ***", got["step"])
} }
+13 -47
View File
@@ -14,59 +14,25 @@ import (
) )
func TestMaxParallelStrategy(t *testing.T) { func TestMaxParallelStrategy(t *testing.T) {
tests := []struct { for _, tt := range []struct {
name string name string
maxParallelString string maxParallelString string
expectedMaxParallel int expectedMaxParallel int
}{ }{
{ {"max-parallel-1", "1", 1},
name: "max-parallel-1", {"max-parallel-2", "2", 2},
maxParallelString: "1", {"max-parallel-default", "", 4},
expectedMaxParallel: 1, {"max-parallel-10-clamped-to-combinations", "10", 5},
}, {"max-parallel-invalid-falls-back-to-default", "tow", 4},
{ } {
name: "max-parallel-2",
maxParallelString: "2",
expectedMaxParallel: 2,
},
{
name: "max-parallel-default",
maxParallelString: "",
expectedMaxParallel: 4,
},
{
name: "max-parallel-10-clamped-to-combinations",
maxParallelString: "10",
expectedMaxParallel: 5,
},
{
name: "max-parallel-invalid-falls-back-to-default",
maxParallelString: "tow",
expectedMaxParallel: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
matrix := map[string][]any{
"version": {1, 2, 3, 4, 5},
}
var rawMatrix yaml.Node var rawMatrix yaml.Node
err := rawMatrix.Encode(matrix) require.NoError(t, rawMatrix.Encode(map[string][]any{"version": {1, 2, 3, 4, 5}}))
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act job := &model.Job{Strategy: &model.Strategy{MaxParallelString: tt.maxParallelString, RawMatrix: rawMatrix}}
job := &model.Job{
Strategy: &model.Strategy{
MaxParallelString: tt.maxParallelString,
RawMatrix: rawMatrix,
},
}
matrixes, err := job.GetMatrixes() matrixes, err := job.GetMatrixes()
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
assert.NotNil(t, matrixes) require.Len(t, matrixes, 5)
assert.Len(t, matrixes, 5)
assert.Equal(t, tt.expectedMaxParallel, maxParallelFor(job.Strategy, len(matrixes))) assert.Equal(t, tt.expectedMaxParallel, maxParallelFor(job.Strategy, len(matrixes)))
}) })
} }
@@ -81,7 +47,7 @@ jobs:
require.NoError(t, err) require.NoError(t, err)
job := workflow.Jobs["test"] job := workflow.Jobs["test"]
rawStrategy := model.CloneYamlNode(job.RawStrategy) rawStrategy := model.CloneYamlNode(job.RawStrategy)
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context())) require.NoError(t, (&runnerImpl{config: &Config{Env: testCheckoutEnv()}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
require.NotNil(t, job.Strategy) require.NotNil(t, job.Strategy)
assert.False(t, job.Strategy.GetFailFast()) assert.False(t, job.Strategy.GetFailFast())
assert.Equal(t, 2, maxParallelFor(job.Strategy, 5)) assert.Equal(t, 2, maxParallelFor(job.Strategy, 5))
@@ -104,7 +70,7 @@ func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
}}, }},
JobID: "test", JobID: "test",
}}}}} }}}}}
runner := &runnerImpl{config: &Config{}} runner := &runnerImpl{config: &Config{Env: testCheckoutEnv()}}
require.ErrorContains(t, runner.NewPlanExecutor(plan)(t.Context()), "could not get job matrix:") require.ErrorContains(t, runner.NewPlanExecutor(plan)(t.Context()), "could not get job matrix:")
+54 -40
View File
@@ -9,15 +9,16 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "slices"
"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"
) )
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it // Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
// from working against Gitea. Both are edited out of the bundle the runner downloaded. // from working against Gitea. Both are edited out of the copy each job container gets.
// //
// isGhes() takes any host that is not github.com, *.ghe.com or *.localhost for GitHub // isGhes() takes any host that is not github.com, *.ghe.com or *.localhost for GitHub
// Enterprise. @actions/cache then forces the v1 API, and @actions/artifact refuses outright, // Enterprise. @actions/cache then forces the v1 API, and @actions/artifact refuses outright,
@@ -40,8 +41,9 @@ const (
cacheURLEnv = "ACTIONS_CACHE_URL" cacheURLEnv = "ACTIONS_CACHE_URL"
resultsURLEnv = "ACTIONS_RESULTS_URL" resultsURLEnv = "ACTIONS_RESULTS_URL"
// localhostHost is the suffix isGhes accepts. // localhostHost is the suffix isGhes accepts, and gheHost the one it tests just before.
localhostHost = ".LOCALHOST" localhostHost = ".LOCALHOST"
gheHost = ".GHE.COM"
// artifactRefusal is the only thing the gate guards in @actions/artifact, which is what makes // artifactRefusal is the only thing the gate guards in @actions/artifact, which is what makes
// such a bundle safe to open. A bundle carrying neither toolkit uses isGhes for something this // such a bundle safe to open. A bundle carrying neither toolkit uses isGhes for something this
@@ -57,8 +59,10 @@ var (
// testing it, while undici, bundled into all of these actions, tests a lowercase ".localhost" // testing it, while undici, bundled into all of these actions, tests a lowercase ".localhost"
// in isURLPotentiallyTrustworthy. Opening that one would tell its HTTP client that every URL // in isURLPotentiallyTrustworthy. Opening that one would tell its HTTP client that every URL
// is trustworthy. Uppercase, the literal occurs nowhere but this test, across 118 bundles // is trustworthy. Uppercase, the literal occurs nowhere but this test, across 118 bundles
// covering every major version of sixteen actions. // covering every major version of sixteen actions. Anchoring on the .GHE.COM test, 5 to 35
localhostTest = regexp.MustCompile(`endsWith\s*\(\s*` + quoted(regexp.QuoteMeta(localhostHost)) + `\s*\)`) // bytes before it, keeps any other library's uppercase test out.
localhostTest = regexp.MustCompile(`endsWith\s*\(\s*` + quoted(regexp.QuoteMeta(gheHost)) + `\s*\)(?s:.{0,128}?)` +
`endsWith\s*\(\s*` + quoted(regexp.QuoteMeta(localhostHost)) + `\s*\)`)
// serviceURLBranches matches both branches of getCacheServiceURL at once: the v1 branch reads // serviceURLBranches matches both branches of getCacheServiceURL at once: the v1 branch reads
// the cache URL and falls back to the results URL, the v2 branch just below reads the results // the cache URL and falls back to the results URL, the v2 branch just below reads the results
@@ -83,67 +87,76 @@ func quoted(pattern string) string {
return "(?:'" + pattern + "'|\"" + pattern + "\"|`" + pattern + "`)" return "(?:'" + pattern + "'|\"" + pattern + "\"|`" + pattern + "`)"
} }
// actionScriptPaths returns the entrypoints of a node action, the only kind with a bundle. Only // actionScriptPaths returns a node action's entrypoints and the chunks bundled beside them,
// remote actions get here: a local one lives in the user's checkout, which the runner does not // relative to checkout. Only remote actions get here: a local one lives in the user's checkout,
// rewrite. // which the runner does not rewrite. Scripts may leave dir for the rest of the checkout, as
func actionScriptPaths(dir string, action *model.Action) []string { // setup-gradle's ../dist does.
func actionScriptPaths(checkout, dir string, action *model.Action) []string {
if action == nil || !action.Runs.Using.IsNode() { if action == nil || !action.Runs.Using.IsNode() {
return nil return nil
} }
root, err := filepath.EvalSymlinks(checkout)
if err != nil {
return nil
}
var paths []string var paths []string
for _, script := range []string{action.Runs.Pre, action.Runs.Main, action.Runs.Post} { for _, script := range []string{action.Runs.Pre, action.Runs.Main, action.Runs.Post} {
if script == "" { if script == "" {
continue continue
} }
path := filepath.Join(dir, script) path, err := filepath.EvalSymlinks(filepath.Join(dir, script))
// `runs` is the action's own yaml, and a key pointing outside its directory is not ours. if err != nil {
if rel, err := filepath.Rel(dir, path); err != nil || strings.HasPrefix(rel, "..") {
continue continue
} }
paths = append(paths, path) // `runs` is the action's own yaml, and a key resolving outside its checkout is not ours.
} bundleDir, err := filepath.Rel(root, filepath.Dir(path))
return paths if err != nil || !filepath.IsLocal(bundleDir) {
} continue
// patchActions returns a restore: the tree is shared, so a job with patching off gets no edits.
func patchActions(ctx context.Context, scripts []string) func() {
restore := map[string][]byte{}
for _, script := range scripts {
original, err := patchBundle(script)
if original != nil {
restore[script] = original // also when the write failed part way through
} }
if err != nil { paths = append(paths, filepath.Join(bundleDir, filepath.Base(path)))
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err) entries, _ := os.ReadDir(filepath.Dir(path))
} for _, entry := range entries {
} if entry.Type().IsRegular() && slices.Contains([]string{".js", ".cjs", ".mjs"}, filepath.Ext(entry.Name())) {
return func() { paths = append(paths, filepath.Join(bundleDir, entry.Name()))
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)
} }
} }
} }
slices.Sort(paths)
return slices.Compact(paths)
} }
// patchBundle returns the bytes it replaced, or nil when it left the bundle alone. // patchedScripts returns the scripts needing the edit, patched, to copy over the job container's copy.
func patchBundle(script string) ([]byte, error) { func patchedScripts(ctx context.Context, checkout string, scripts []string) []*container.FileEntry {
var patched []*container.FileEntry
for _, script := range scripts {
body, mode, err := patchedScript(filepath.Join(checkout, script))
if err != nil {
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
} else if body != nil {
patched = append(patched, &container.FileEntry{Name: filepath.ToSlash(script), Mode: int64(mode), Body: string(body)})
}
}
return patched
}
// patchedScript returns the bundle patched, or nil when it is left alone.
func patchedScript(script string) ([]byte, os.FileMode, error) {
info, err := os.Stat(script) info, err := os.Stat(script)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
if info.Size() > maxBundleSize { if info.Size() > maxBundleSize {
return nil, nil return nil, 0, nil
} }
data, err := os.ReadFile(script) data, err := os.ReadFile(script)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
patched, ok := patchedBundle(data) patched, ok := patchedBundle(data)
if !ok { if !ok {
return nil, nil return nil, 0, nil
} }
return data, os.WriteFile(script, patched, info.Mode().Perm()) return patched, info.Mode().Perm(), nil
} }
// 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
@@ -164,7 +177,8 @@ func patchedBundle(data []byte) ([]byte, bool) {
opened := localhostTest.ReplaceAllFunc(data, func(test []byte) []byte { opened := localhostTest.ReplaceAllFunc(data, func(test []byte) []byte {
// Drop the hostname from the test rather than rewriting the call, so the bundle's own // Drop the hostname from the test rather than rewriting the call, so the bundle's own
// quoting survives and the result stays valid even inside a string literal. // quoting survives and the result stays valid even inside a string literal.
return bytes.Replace(test, []byte(localhostHost), nil, 1) before, after, _ := bytes.CutLast(test, []byte(localhostHost))
return slices.Concat(before, after)
}) })
if cache { if cache {
opened = serviceURLBranches.ReplaceAll(opened, cacheURLFirst) opened = serviceURLBranches.ReplaceAll(opened, cacheURLFirst)
+69 -92
View File
@@ -20,16 +20,13 @@ import (
"gitea.com/gitea/runner/act/artifactcache" "gitea.com/gitea/runner/act/artifactcache"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// actionsCacheRef pins the actions/cache release this is verified against. Bump it
// deliberately: a new release is exactly what can stop the patch matching.
const actionsCacheRef = "v6.1.0" const actionsCacheRef = "v6.1.0"
// bundleFromGitHub downloads one entrypoint, keeping it in the user cache dir so repeated runs
// cost nothing. The bundles are megabytes, too large to vendor.
func bundleFromGitHub(t *testing.T, repo, ref, path string) string { func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
t.Helper() t.Helper()
@@ -52,6 +49,7 @@ func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
t.Skipf("cannot reach %s: %v", url, err) t.Skipf("cannot reach %s: %v", url, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
require.NotEqual(t, http.StatusNotFound, resp.StatusCode, "%s is not in this release", url)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Skipf("GET %s: %s", url, resp.Status) t.Skipf("GET %s: %s", url, resp.Status)
} }
@@ -63,17 +61,28 @@ func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
return bundle return bundle
} }
// jobEnv is the environment a job gets from this runner, which is what decides where an action's
// toolkit looks for the cache and artifact services.
type jobEnv struct { type jobEnv struct {
workspace, runnerTemp string workspace, runnerTemp string
cacheURL, resultsURL string cacheURL, resultsURL string
token string token string
} }
// runActionEntrypoint runs one action entrypoint the way a job would. Adding another action to these tests func newActionJobEnv(t *testing.T, token string, credential artifactcache.JobCredential) jobEnv {
// means downloading its entrypoint with bundleFromGitHub and calling this with its inputs, whose t.Helper()
// names are the ones the action's own action.yml uses.
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"})
require.NoError(t, err)
t.Cleanup(func() { _ = handler.Close() })
t.Cleanup(handler.RegisterJob(token, credential))
return jobEnv{
workspace: tempDirPath(t),
runnerTemp: tempDirPath(t),
cacheURL: handler.ExternalURL(),
resultsURL: handler.ExternalURL(),
token: token,
}
}
func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[string]string) string { func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[string]string) string {
t.Helper() t.Helper()
state := filepath.Join(env.runnerTemp, "state") state := filepath.Join(env.runnerTemp, "state")
@@ -108,20 +117,19 @@ func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[str
return string(out) return string(out)
} }
// patchedAction downloads one entrypoint and patches it exactly as a downloaded action would be.
func patchedAction(t *testing.T, repo, ref, entrypoint string) string { func patchedAction(t *testing.T, repo, ref, entrypoint string) string {
t.Helper() t.Helper()
body, err := os.ReadFile(bundleFromGitHub(t, repo, ref, entrypoint)) body, err := os.ReadFile(bundleFromGitHub(t, repo, ref, entrypoint))
require.NoError(t, err) require.NoError(t, err)
if patched, ok := patchedBundle(body); ok {
body = patched
}
script := filepath.Join(tempDirPath(t), filepath.Base(entrypoint)) script := filepath.Join(tempDirPath(t), filepath.Base(entrypoint))
require.NoError(t, os.WriteFile(script, body, 0o600)) require.NoError(t, os.WriteFile(script, body, 0o600))
patchActions(t.Context(), []string{script})
return script return script
} }
// tempDirPath is TempDir with symlinks resolved, because macOS hands out /var paths that resolve
// to /private/var and the client derives archive paths relative to the workspace.
func tempDirPath(t *testing.T) string { func tempDirPath(t *testing.T) string {
t.Helper() t.Helper()
@@ -130,32 +138,14 @@ func tempDirPath(t *testing.T) string {
return dir return dir
} }
// The whole chain against the pinned release, whose bundles ship unminified: patch them, run the
// real client with an ordinary Gitea server URL and a results URL that goes nowhere, and have it
// save and restore through this runner's cache server. The unreachable results URL is the point,
// it is what proves the cache reaches the runner without the runner fronting Gitea. If a release
// stops matching the patch the client falls back to v1 and this fails on the version line, which
// is the signal to look at the new bundle.
func TestCacheServiceV2EndToEnd(t *testing.T) { func TestCacheServiceV2EndToEnd(t *testing.T) {
t.Parallel()
requireHostTools(t, "node") requireHostTools(t, "node")
restore := patchedAction(t, "actions/cache", actionsCacheRef, "dist/restore/index.js") restore := patchedAction(t, "actions/cache", actionsCacheRef, "dist/restore/index.js")
save := patchedAction(t, "actions/cache", actionsCacheRef, "dist/save/index.js") save := patchedAction(t, "actions/cache", actionsCacheRef, "dist/save/index.js")
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"}) env := newActionJobEnv(t, "e2e-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"})
require.NoError(t, err)
t.Cleanup(func() { _ = handler.Close() })
const token, repo = "e2e-runtime-token", "testuser/testrepo"
handler.RegisterJob(token, artifactcache.JobCredential{Repo: repo})
env := jobEnv{
workspace: tempDirPath(t),
runnerTemp: tempDirPath(t),
cacheURL: handler.ExternalURL(),
// The results service is the cache server's too, which is what the runner advertises.
resultsURL: handler.ExternalURL(),
token: token,
}
require.NoError(t, os.MkdirAll(filepath.Join(env.workspace, "to-cache"), 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(env.workspace, "to-cache"), 0o755))
content := []byte("cached through the patched gate") content := []byte("cached through the patched gate")
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "to-cache", "data.txt"), content, 0o600)) require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "to-cache", "data.txt"), content, 0o600))
@@ -177,46 +167,33 @@ func TestCacheServiceV2EndToEnd(t *testing.T) {
got, err := os.ReadFile(filepath.Join(env.workspace, "to-cache", "data.txt")) got, err := os.ReadFile(filepath.Join(env.workspace, "to-cache", "data.txt"))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, content, got) assert.Equal(t, content, got)
// Untouched, the same client takes a Gitea host for GHES and stays on v1, which reaches the
// cache server on its own address. That is what a runner without a results service of its own
// leaves its jobs with, so it has to round trip too.
env.workspace = tempDirPath(t) env.workspace = tempDirPath(t)
v1 := runActionEntrypoint(t, bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/restore/index.js"), env, inputs) v1 := runActionEntrypoint(t, bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/restore/index.js"), env, inputs)
require.Contains(t, v1, "Cache service version: v1") require.Contains(t, v1, "Cache service version: v1")
require.Contains(t, v1, "Cache restored from key: "+key) require.Contains(t, v1, "Cache restored from key: "+key)
} }
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
// between them runs from 159 to 1179 bytes across these actions, which is why neither edit is
// anchored on that distance. One entrypoint from each of the families that bundle the cache
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
// the two shapes and leaving every cache on v1.
func TestPatchedBundleAcrossActions(t *testing.T) { func TestPatchedBundleAcrossActions(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
repo, ref, path string repo, ref, path string
wantPatched bool wantPatched bool
}{ }{
// The cache toolkit, in each bundler shape and from a spread of ecosystems, including the
// actions that drive a Go or a Rust cache client of their own.
{"actions/cache", actionsCacheRef, "dist/restore/index.js", true}, {"actions/cache", actionsCacheRef, "dist/restore/index.js", true},
{"actions/setup-node", "v7.1.0", "dist/cache-save/index.js", true}, {"actions/setup-node", "v7.1.0", "dist/cache-save/index.js", true},
{"actions/setup-python", "v7.0.0", "dist/setup/index.js", true}, {"actions/setup-python", "v7.0.0", "dist/setup/index.js", true},
{"actions/setup-go", "v7.0.0", "dist/setup/index.js", true}, {"actions/setup-go", "v7.0.0", "dist/setup/index.js", true},
{"actions/setup-java", "v5.7.0", "dist/setup/index.js", true}, {"actions/setup-java", "v5.7.0", "dist/setup/index.js", true},
{"actions/setup-java", "v6.1.0", "dist/setup/295.index.js", true},
{"ruby/setup-ruby", "v1.331.0", "dist/index.js", true}, {"ruby/setup-ruby", "v1.331.0", "dist/index.js", true},
{"pnpm/action-setup", "v6.1.0", "dist/index.js", true}, {"pnpm/action-setup", "v6.1.0", "dist/index.js", true},
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js", true}, {"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js", true},
{"Swatinem/rust-cache", "v2.9.1", "dist/restore/index.js", true}, {"Swatinem/rust-cache", "v2.9.1", "dist/restore/index.js", true},
{"Swatinem/rust-cache", "v2.9.2", "dist/cache-CTfQoCTL.js", true},
{"docker/build-push-action", "v7.4.0", "dist/index.cjs", true}, {"docker/build-push-action", "v7.4.0", "dist/index.cjs", true},
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect. {"gradle/actions", "v6.4.0", "dist/setup-gradle/main/index.js", true},
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
// releases before it refuse in a shape the runner leaves alone.
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true}, {"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true},
{"actions/upload-artifact", "v7.0.2", "dist/upload/index.js", true}, {"actions/upload-artifact", "v7.0.2", "dist/upload/index.js", true},
{"actions/download-artifact", "v8.0.2", "dist/index.js", true}, {"actions/download-artifact", "v8.0.2", "dist/index.js", true},
// Neither toolkit's gate, so these have to come back byte for byte. sccache-action is the
// one that exports ACTIONS_CACHE_SERVICE_V2 itself, for the Rust client it installs.
{"actions/checkout", "v7.0.1", "dist/index.js", false}, {"actions/checkout", "v7.0.1", "dist/index.js", false},
{"mozilla-actions/sccache-action", "v0.0.11", "dist/setup/index.js", false}, {"mozilla-actions/sccache-action", "v0.0.11", "dist/setup/index.js", false},
} { } {
@@ -235,10 +212,8 @@ func TestPatchedBundleAcrossActions(t *testing.T) {
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed") assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
if !strings.Contains(string(data), CacheServiceV2Env) { if !strings.Contains(string(data), CacheServiceV2Env) {
return // the artifact toolkit: a refusal to open, and no URL to move return
} }
// Only the reads inside getCacheServiceURL are rewritten. The others, such as the
// feature-availability check, must be left as they are.
assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"), assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"),
"the cache service URL was not redirected") "the cache service URL was not redirected")
assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv), assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv),
@@ -247,11 +222,8 @@ func TestPatchedBundleAcrossActions(t *testing.T) {
} }
} }
// The stock artifact actions refuse on a Gitea host until the gate is opened, and then they talk
// to the results service, which is this runner's cache server forwarding the artifact half on to
// Gitea. Running the real upload-artifact against a stand-in Gitea covers both halves at once:
// the patch, and the forwarding the job's registration set up.
func TestUploadArtifactThroughTheResultsService(t *testing.T) { func TestUploadArtifactThroughTheResultsService(t *testing.T) {
t.Parallel()
requireHostTools(t, "node") requireHostTools(t, "node")
var called []string var called []string
@@ -275,12 +247,12 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) {
case "download": case "download":
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
_, _ = w.Write(zipped) _, _ = w.Write(zipped)
default: // the zip on its way up, in the blocks the Azure protocol puts it in default:
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
switch r.URL.Query().Get("comp") { switch r.URL.Query().Get("comp") {
case "block": case "block":
zipped = append(zipped, body...) zipped = append(zipped, body...)
case "blocklist": // the ordering document, not content case "blocklist":
default: default:
zipped = body zipped = body
} }
@@ -288,24 +260,11 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) {
} }
})) }))
defer gitea.Close() defer gitea.Close()
env := newActionJobEnv(t, "e30."+base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`))+".sig",
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"}) artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})
require.NoError(t, err)
t.Cleanup(func() { _ = handler.Close() })
// The artifact client decodes the runtime token for the run ids it puts in its requests, where
// the cache client only presents it, so this one has to be shaped like Gitea's.
token := "e30." + base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`)) + ".sig"
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})()
upload := patchedAction(t, "actions/upload-artifact", "v7.0.2", "dist/upload/index.js") upload := patchedAction(t, "actions/upload-artifact", "v7.0.2", "dist/upload/index.js")
env := jobEnv{
workspace: tempDirPath(t),
runnerTemp: tempDirPath(t),
cacheURL: handler.ExternalURL(),
resultsURL: handler.ExternalURL(),
token: token,
}
uploaded := []byte("through the results service") uploaded := []byte("through the results service")
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "artifact.txt"), uploaded, 0o600)) require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "artifact.txt"), uploaded, 0o600))
@@ -316,9 +275,6 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) {
}) })
require.Contains(t, out, "has been successfully uploaded") require.Contains(t, out, "has been successfully uploaded")
// And back down again: listing and downloading go the same way, and the signed URL the
// artifact service hands out is fetched straight from it.
download := patchedAction(t, "actions/download-artifact", "v8.0.2", "dist/index.js") download := patchedAction(t, "actions/download-artifact", "v8.0.2", "dist/index.js")
env.workspace = tempDirPath(t) env.workspace = tempDirPath(t)
out = runActionEntrypoint(t, download, env, map[string]string{ out = runActionEntrypoint(t, download, env, map[string]string{
@@ -335,27 +291,13 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) {
assert.Equal(t, uploaded, got) assert.Equal(t, uploaded, got)
} }
// The setup actions carry the same toolkit and reach the same service, from a key of their own
// making. setup-node is the cheapest of them to run: given a lockfile and no version to install,
// it does the cache lookup and nothing else.
func TestSetupActionFindsTheCacheService(t *testing.T) { func TestSetupActionFindsTheCacheService(t *testing.T) {
t.Parallel()
requireHostTools(t, "node", "npm") requireHostTools(t, "node", "npm")
setup := patchedAction(t, "actions/setup-node", "v7.1.0", "dist/setup/index.js") setup := patchedAction(t, "actions/setup-node", "v7.1.0", "dist/setup/index.js")
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"}) env := newActionJobEnv(t, "setup-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"})
require.NoError(t, err)
t.Cleanup(func() { _ = handler.Close() })
const token = "setup-runtime-token"
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo"})()
env := jobEnv{
workspace: tempDirPath(t),
runnerTemp: tempDirPath(t),
cacheURL: handler.ExternalURL(),
resultsURL: handler.ExternalURL(),
token: token,
}
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "package-lock.json"), require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "package-lock.json"),
[]byte(`{"lockfileVersion":3}`), 0o600)) []byte(`{"lockfileVersion":3}`), 0o600))
@@ -364,3 +306,38 @@ func TestSetupActionFindsTheCacheService(t *testing.T) {
require.Contains(t, out, "Cache service version: v2") require.Contains(t, out, "Cache service version: v2")
require.Contains(t, out, "npm cache is not found", "the lookup did not reach the cache server") require.Contains(t, out, "npm cache is not found", "the lookup did not reach the cache server")
} }
func TestCodeSplitActionFindsTheCacheService(t *testing.T) {
requireHostTools(t, "node", "rustc")
dir := tempDirPath(t)
for _, name := range []string{
"action.yml", "dist/restore.js", "dist/save.js", "dist/cleanup-BWEbZ6YT.js",
"dist/cache-CTfQoCTL.js", "dist/cache-DC26rUaF.js", "dist/state-cjs-CkvFUaNy.js",
} {
body, err := os.ReadFile(bundleFromGitHub(t, "Swatinem/rust-cache", "v2.9.2", name))
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600))
}
body, err := os.ReadFile(filepath.Join(dir, "action.yml"))
require.NoError(t, err)
action, err := model.ReadAction(strings.NewReader(string(body)))
require.NoError(t, err)
inputs := map[string]string{"lookup-only": "true", "add-rust-environment-hash-key": "false", "cache-bin": "false"}
for name, input := range action.Inputs {
if _, ok := inputs[name]; !ok {
inputs[name] = input.Default
}
}
t.Setenv("CARGO_HOME", tempDirPath(t))
for _, entry := range patchedScripts(t.Context(), dir, actionScriptPaths(dir, dir, action)) {
require.NoError(t, os.WriteFile(filepath.Join(dir, entry.Name), []byte(entry.Body), 0o600))
}
out := runActionEntrypoint(t, filepath.Join(dir, action.Runs.Main),
newActionJobEnv(t, "split-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"}), inputs)
require.Contains(t, out, "Cache service version: v2")
require.Contains(t, out, "No cache found.", "the lookup did not reach the cache server")
}
+106 -61
View File
@@ -8,9 +8,12 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
"gitea.com/gitea/runner/act/container"
"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"
@@ -84,6 +87,21 @@ func TestPatchedBundleLeavesTrustworthyURLCheckAlone(t *testing.T) {
assert.True(t, gateOpened(string(out))) assert.True(t, gateOpened(string(out)))
} }
func TestPatchedBundleOpensOnlyTheIsGhesTest(t *testing.T) {
const unrelated = `function isLocalName(h){return h.toUpperCase().endsWith('.LOCALHOST')}`
for _, bundle := range []string{unrelated + "\n" + gateTSC, gateTSC + "\n" + unrelated} {
out, patched := patchedBundle([]byte(bundle))
require.True(t, patched)
assert.Contains(t, string(out), unrelated)
assert.Contains(t, string(out), "!h.endsWith('')")
}
apart := strings.Replace(gateTSC, "&&!h.endsWith('.LOCALHOST')", "&&"+strings.Repeat(" ", 200)+"!h.endsWith('.LOCALHOST')", 1)
_, patched := patchedBundle([]byte(apart))
assert.False(t, patched)
}
// The artifact toolkit puts the same gate in front of a plain refusal, with no URL to move, so // The artifact toolkit puts the same gate in front of a plain refusal, with no URL to move, so
// opening it is what lets the stock upload-artifact work against Gitea instead of aborting. // opening it is what lets the stock upload-artifact work against Gitea instead of aborting.
func TestPatchedBundleOpensTheArtifactRefusal(t *testing.T) { func TestPatchedBundleOpensTheArtifactRefusal(t *testing.T) {
@@ -117,39 +135,36 @@ func gateOpened(body string) bool {
// an ordinary Gitea host, the cache server for the service URL, and the results URL when there is // an ordinary Gitea host, the cache server for the service URL, and the results URL when there is
// no cache server. Unpatched, the same bundle must still choose v1, or the patch proves nothing. // no cache server. Unpatched, the same bundle must still choose v1, or the patch proves nothing.
func TestPatchedBundleBehavesInNode(t *testing.T) { func TestPatchedBundleBehavesInNode(t *testing.T) {
t.Parallel()
requireHostTools(t, "node") requireHostTools(t, "node")
eval := func(t *testing.T, bundle, prelude, cacheURL string) string {
t.Helper()
script := prelude + bundle + "\nprocess.stdout.write(getCacheServiceVersion()+' '+getCacheServiceURL())"
cmd := exec.CommandContext(t.Context(), "node", "-e", script)
cmd.Env = append(os.Environ(),
"ACTIONS_CACHE_SERVICE_V2=true",
"ACTIONS_CACHE_URL="+cacheURL,
"ACTIONS_RESULTS_URL=https://gitea.example",
"GITHUB_SERVER_URL=https://gitea.example",
)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "%s", out)
return string(out)
}
for _, tc := range []struct{ name, bundle, prelude string }{ for _, tc := range []struct{ name, bundle, prelude string }{
{"tsc", gateTSC, ""}, {"tsc", gateTSC, ""},
{"webpack", gateWebpack, "const getCacheServiceVersion=()=>config_getCacheServiceVersion();"}, {"webpack", gateWebpack, "const getCacheServiceVersion=()=>config_getCacheServiceVersion();"},
{"esbuild", gateEsbuild, "var vu={};const getCacheServiceVersion=()=>XK(),getCacheServiceURL=()=>YK();"}, {"esbuild", gateEsbuild, "var vu={};const getCacheServiceVersion=()=>XK(),getCacheServiceURL=()=>YK();"},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Unpatched, a Gitea host is taken for GHES: v1, whose branch already reads the
// cache URL. The patch has to move the version without moving that.
assert.Equal(t, "v1 http://cache:8088/", eval(t, tc.bundle, tc.prelude, "http://cache:8088/"))
patched, ok := patchedBundle([]byte(tc.bundle)) patched, ok := patchedBundle([]byte(tc.bundle))
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "v2 http://cache:8088/", eval(t, string(patched), tc.prelude, "http://cache:8088/")) evaluate := func(bundle string) string {
assert.Equal(t, "v2 https://gitea.example", eval(t, string(patched), tc.prelude, ""), return "(() => {" + tc.prelude + bundle + "\nconsole.log(getCacheServiceVersion()+' '+getCacheServiceURL());})();\n"
}
cmd := exec.CommandContext(t.Context(), "node", "-e", evaluate(tc.bundle)+evaluate(string(patched))+
"process.env.ACTIONS_CACHE_URL='';\n"+evaluate(string(patched)))
cmd.Env = append(os.Environ(),
"ACTIONS_CACHE_SERVICE_V2=true",
"ACTIONS_CACHE_URL=http://cache:8088/",
"ACTIONS_RESULTS_URL=https://gitea.example",
"GITHUB_SERVER_URL=https://gitea.example",
)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "%s", out)
results := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n")
require.Len(t, results, 3)
assert.Equal(t, "v1 http://cache:8088/", results[0])
assert.Equal(t, "v2 http://cache:8088/", results[1])
assert.Equal(t, "v2 https://gitea.example", results[2],
"with no cache server the results URL is still the fallback") "with no cache server the results URL is still the fallback")
}) })
} }
@@ -159,6 +174,7 @@ func TestPatchedBundleBehavesInNode(t *testing.T) {
// the gate inside a double-quoted literal. Rewriting the call rather than emptying its argument // the gate inside a double-quoted literal. Rewriting the call rather than emptying its argument
// would end that string early and leave the bundle unparseable. // would end that string early and leave the bundle unparseable.
func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) { func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
t.Parallel()
requireHostTools(t, "node") requireHostTools(t, "node")
escaped := strings.ReplaceAll(gateTSC, `"`, `\"`) escaped := strings.ReplaceAll(gateTSC, `"`, `\"`)
@@ -172,34 +188,24 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
require.NoError(t, err, "%s", checked) require.NoError(t, err, "%s", checked)
} }
// An action with a pre step is copied, and so patched, twice. // A bundle an older runner left patched in the shared copy is copied as it is.
func TestPatchBundleIsIdempotent(t *testing.T) { func TestPatchedScriptIsIdempotent(t *testing.T) {
script := bundleFile(t, gateTSC) script := bundleFile(t, gateTSC)
original, err := patchBundle(script) patched, _, err := patchedScript(script)
require.NoError(t, err)
require.NotNil(t, original)
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)))
original, err = patchBundle(script) require.NoError(t, os.WriteFile(script, patched, 0o600))
again, _, err := patchedScript(script)
require.NoError(t, err) require.NoError(t, err)
assert.Nil(t, original, "a patched bundle is not patched again") assert.Nil(t, again, "a patched bundle is not patched again")
again, err := os.ReadFile(script)
require.NoError(t, err)
assert.Equal(t, string(patched), string(again))
} }
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) { func TestPatchedScriptLeavesOtherActionsAlone(t *testing.T) {
script := bundleFile(t, `console.log("checkout")`) patched, _, err := patchedScript(bundleFile(t, `console.log("checkout")`))
original, err := patchBundle(script)
require.NoError(t, err) require.NoError(t, err)
assert.Nil(t, original) assert.Nil(t, patched)
body, err := os.ReadFile(script)
require.NoError(t, err)
assert.Equal(t, `console.log("checkout")`, string(body))
} }
func bundleFile(t *testing.T, body string) string { func bundleFile(t *testing.T, body string) string {
@@ -211,58 +217,97 @@ func bundleFile(t *testing.T, body string) string {
} }
func TestActionScriptPaths(t *testing.T) { func TestActionScriptPaths(t *testing.T) {
node := &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "dist/restore/index.js", Post: "dist/save/index.js"}} dir, err := filepath.EvalSymlinks(t.TempDir())
assert.Equal(t, []string{"/a/dist/restore/index.js", "/a/dist/save/index.js"}, actionScriptPaths("/a", node)) require.NoError(t, err)
dist := filepath.Join(dir, "dist")
require.NoError(t, os.MkdirAll(filepath.Join(dist, "sub"), 0o755))
require.NoError(t, os.Mkdir(filepath.Join(dist, "directory.js"), 0o755))
for _, name := range []string{"dist/restore.js", "dist/save.js", "dist/cache-CTfQoCTL.js", "dist/295.index.js", "dist/chunk.cjs", "dist/chunk.mjs", "dist/LICENSE.txt", "dist/sub/deep.js", "pre.js"} {
require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o644))
}
outside, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(outside, "index.js"), nil, 0o644))
require.NoError(t, os.Symlink(filepath.Join(outside, "index.js"), filepath.Join(dist, "linked.js")))
var chunks []string
for _, name := range []string{"295.index.js", "cache-CTfQoCTL.js", "chunk.cjs", "chunk.mjs", "restore.js", "save.js"} {
chunks = append(chunks, filepath.Join("dist", name))
}
node := &model.Action{Runs: model.ActionRuns{Using: "node20", Pre: "pre.js", Main: "dist/restore.js", Post: "dist/save.js"}}
assert.Equal(t, append(slices.Clone(chunks), "pre.js"), actionScriptPaths(dir, dir, node))
// Only a node action has a bundle to patch. // Only a node action has a bundle to patch.
assert.Nil(t, actionScriptPaths("/a", &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "alpine"}})) assert.Nil(t, actionScriptPaths(dir, dir, &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "alpine"}}))
assert.Nil(t, actionScriptPaths("/a", nil)) assert.Nil(t, actionScriptPaths(dir, dir, nil))
// An action naming a file outside its own directory does not get it rewritten. // An action naming a file outside its checkout does not get it rewritten.
escaping := &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../../elsewhere/index.js"}} require.NoError(t, os.Symlink(outside, filepath.Join(dir, "linked")))
assert.Nil(t, actionScriptPaths("/a", escaping)) escaping, err := filepath.Rel(dir, filepath.Join(outside, "index.js"))
require.NoError(t, err)
for _, main := range []string{"../../elsewhere/index.js", escaping, "dist/linked.js", "linked/index.js"} {
assert.Nil(t, actionScriptPaths(dir, dir, &model.Action{Runs: model.ActionRuns{Using: "node20", Main: main}}), main)
}
require.NoError(t, os.Symlink("dist", filepath.Join(dir, "build")))
assert.Equal(t, chunks, actionScriptPaths(dir, dir, &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "build/restore.js"}}))
assert.Equal(t, chunks, actionScriptPaths(dir, filepath.Join(dir, "setup"), &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../dist/restore.js"}}))
} }
func TestPatchActionsAtTheContainerCopy(t *testing.T) { func TestPatchActionsAtTheContainerCopy(t *testing.T) {
copiedBundle := func(t *testing.T, noPatch bool) string { copiedBundles := func(t *testing.T, noPatch bool) ([]string, []*container.FileEntry) {
t.Helper() t.Helper()
cm := &containerMock{} cm := &containerMock{}
sar := &stepActionRemote{ sar := &stepActionRemote{
Step: &model.Step{Uses: "owner/repo/sub@v1"}, Step: &model.Step{Uses: "owner/repo/sub@v1"},
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"}, remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}}, action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../dist/index.js"}},
actionDir: t.TempDir(), actionDir: t.TempDir(),
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{ActionCacheDir: t.TempDir(), NoActionPatch: noPatch}, Config: &Config{ActionCacheDir: t.TempDir(), NoActionPatch: noPatch},
JobContainer: cm, JobContainer: cm,
}, },
} }
script := filepath.Join(sar.actionDir, "sub", "index.js") script := filepath.Join(sar.actionDir, "dist", "cache.js")
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755)) require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(script), "index.js"), []byte("require('./cache.js')"), 0o600))
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600)) require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
var copied string var calls []string
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error { var copied []*container.FileEntry
body, err := os.ReadFile(script) cm.On("CopyDir", "/var/run/act/actions/repo/", sar.actionDir+"/", false, true).Return(func(context.Context) error {
require.NoError(t, err) calls = append(calls, "CopyDir")
copied = string(body) return nil
})
cm.On("Copy", "/var/run/act/actions/repo/", mock.Anything).Run(func(args mock.Arguments) {
copied = args.Get(1).([]*container.FileEntry)
}).Return(func(context.Context) error {
calls = append(calls, "Copy")
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) source, err := os.ReadFile(script)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, gateTSC, string(source), "the shared copy is left as it was found") assert.Equal(t, gateTSC, string(source), "the shared copy is never written")
return copied return calls, copied
} }
t.Run("patched on its way in", func(t *testing.T) { t.Run("patched over the container's copy", func(t *testing.T) {
assert.True(t, gateOpened(copiedBundle(t, false))) calls, copied := copiedBundles(t, false)
assert.Equal(t, []string{"CopyDir", "Copy"}, calls)
require.Len(t, copied, 1)
assert.Equal(t, "dist/cache.js", copied[0].Name)
assert.Equal(t, int64(0o600), copied[0].Mode)
assert.True(t, gateOpened(copied[0].Body))
}) })
// The escape hatch, for an action the edit breaks: the artifact actions refuse again, and the // The escape hatch, for an action the edit breaks: the artifact actions refuse again, and the
// cache client keeps to v1. // cache client keeps to v1.
t.Run("as shipped when the runner is told not to patch", func(t *testing.T) { t.Run("as shipped when the runner is told not to patch", func(t *testing.T) {
assert.Equal(t, gateTSC, copiedBundle(t, true)) calls, copied := copiedBundles(t, true)
assert.Equal(t, []string{"CopyDir"}, calls)
assert.Empty(t, copied)
}) })
} }
+24 -38
View File
@@ -107,7 +107,13 @@ func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string, cac
return err return err
} }
runner, err := newReusableWorkflowRunner(rc) runner, err := (&runnerImpl{
config: rc.Config,
caller: &caller{
runContext: rc,
reusedWorkflowJobResults: map[string]string{},
},
}).configure()
if err != nil { if err != nil {
return err return err
} }
@@ -119,20 +125,6 @@ func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string, cac
} }
} }
func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) {
runner := &runnerImpl{
config: rc.Config,
eventJSON: rc.EventJSON,
caller: &caller{
runContext: rc,
reusedWorkflowJobResults: map[string]string{}, // For Gitea
},
}
return runner.configure()
}
type remoteReusableWorkflow struct { type remoteReusableWorkflow struct {
*model.ReusableWorkflowUses *model.ReusableWorkflowUses
URL string URL string
@@ -163,38 +155,32 @@ func newRemoteReusableWorkflow(baseURL, uses string) (*remoteReusableWorkflow, e
// For Gitea // For Gitea
func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Executor { func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
caller := runner.caller
allJobDone := true
hasFailure := false hasFailure := false
for _, result := range caller.reusedWorkflowJobResults { for _, result := range runner.caller.reusedWorkflowJobResults {
if result == "pending" { if result == "pending" {
allJobDone = false return nil
break
} }
if result == "failure" { if result == "failure" {
hasFailure = true hasFailure = true
} }
} }
if allJobDone { reusedWorkflowJobResult := "success"
reusedWorkflowJobResult := "success" reusedWorkflowJobResultMessage := "succeeded"
reusedWorkflowJobResultMessage := "succeeded" if hasFailure {
if hasFailure { reusedWorkflowJobResult = "failure"
reusedWorkflowJobResult = "failure" reusedWorkflowJobResultMessage = "failed"
reusedWorkflowJobResultMessage = "failed" }
}
if rc.caller != nil { if rc.caller != nil {
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, reusedWorkflowJobResult) rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, reusedWorkflowJobResult)
} else { } else {
// Serialize this shared Job.Result write against the other matrix combos // Serialize this shared Job.Result write against the other matrix combos
// and setJobResult (same lockJob key). // and setJobResult (same lockJob key).
unlock := lockJob(rc.Run.Job()) unlock := lockJob(rc.Run.Job())
rc.result(reusedWorkflowJobResult) rc.result(reusedWorkflowJobResult)
unlock() unlock()
common.Logger(ctx).With("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage) common.Logger(ctx).With("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
}
} }
return nil return nil
+3 -1
View File
@@ -27,6 +27,7 @@ import (
// ref (branch/tag) must reflect the new tip on every invocation, not stay pinned // ref (branch/tag) must reflect the new tip on every invocation, not stay pinned
// to the cache populated on the first run. // to the cache populated on the first run.
func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) { func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
t.Parallel()
if _, err := exec.LookPath("git"); err != nil { if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available in PATH") t.Skip("git not available in PATH")
} }
@@ -52,7 +53,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
gitMust(t, workDir, "push", "-u", "origin", "master") gitMust(t, workDir, "push", "-u", "origin", "master")
rc := &RunContext{ rc := &RunContext{
Config: &Config{}, Config: &Config{Env: testCheckoutEnv()},
Run: &model.Run{ Run: &model.Run{
JobID: "j1", JobID: "j1",
Workflow: &model.Workflow{ Workflow: &model.Workflow{
@@ -149,6 +150,7 @@ func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600)) require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
rc := &RunContext{ rc := &RunContext{
Config: &Config{ Config: &Config{
Env: testCheckoutEnv(),
GitHubInstance: server.URL, GitHubInstance: server.URL,
Secrets: map[string]string{"GITEA_TOKEN": "token"}, Secrets: map[string]string{"GITEA_TOKEN": "token"},
InsecureSkipTLS: true, InsecureSkipTLS: true,
+77 -114
View File
@@ -81,10 +81,11 @@ type RunContext struct {
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
platformImage string // container.image or the runs-on pick, resolved once by isEnabled platformImage string // container.image or the runs-on pick, resolved once by isEnabled
jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown
runtimeToken string // ACTIONS_RUNTIME_TOKEN of the workflow run, shared with its called workflows and composite actions
jobEnv map[string]string // workflow and job env as evaluated for the container and service specs jobEnv map[string]string // workflow and job env as evaluated for the container and service specs
dockerActionImages map[*model.Step]string // Dockerfile action images built for this job, removed when it ends dockerActionImages map[*model.Step]string // Dockerfile action images built for this job, removed when it ends
actionDownloads map[string]actionDownload // remote action repositories by clone URL and ref, fetched once per job actionDownloads map[string]*actionDownload // remote action repositories by clone URL and ref, fetched once per job
// 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
@@ -114,6 +115,7 @@ type RunContext struct {
stepEnv map[string]string stepEnv map[string]string
unsecureCommandErr error // refused ::set-env::/::add-path::, turned into a step failure unsecureCommandErr error // refused ::set-env::/::add-path::, turned into a step failure
unsecureCommandMu sync.Mutex unsecureCommandMu sync.Mutex
checkoutPins sync.Map // git lookups of contexts no run has pinned
} }
// serviceContainer pairs a service container with the workflow id that keys job.services. // serviceContainer pairs a service container with the workflow id that keys job.services.
@@ -164,8 +166,12 @@ func (rc *RunContext) markInterrupted(err error) {
} }
} }
func (rc *RunContext) AddMask(mask string) { // AddMask also hands the mask to the job logger's extra handler, which receives records unmasked.
func (rc *RunContext) AddMask(ctx context.Context, mask string) {
rc.Masks = append(rc.Masks, mask) rc.Masks = append(rc.Masks, mask)
if masker, ok := common.LoggerHandler(ctx).(interface{ AddMask(string) }); ok {
masker.AddMask(mask)
}
} }
func (rc *RunContext) String() string { func (rc *RunContext) String() string {
@@ -188,6 +194,9 @@ func (rc *RunContext) GetEnv() map[string]string {
rc.Env = mergeMaps(rc.Run.Workflow.Env, job.Environment(), rc.Config.Env) rc.Env = mergeMaps(rc.Run.Workflow.Env, job.Environment(), rc.Config.Env)
} }
} }
if rc.runtimeToken != "" {
rc.Env["ACTIONS_RUNTIME_TOKEN"] = rc.runtimeToken // as the daemon's Config.Env carries its token
}
} }
if !rc.Config.DisableActEnv { if !rc.Config.DisableActEnv {
rc.Env["ACT"] = "true" rc.Env["ACT"] = "true"
@@ -225,8 +234,7 @@ func (rc *RunContext) networkNameForGitea() (string, bool) {
} }
func getDockerDaemonSocketMountPath(daemonPath string) string { func getDockerDaemonSocketMountPath(daemonPath string) string {
if before, after, ok := strings.Cut(daemonPath, "://"); ok { if scheme, after, ok := strings.Cut(daemonPath, "://"); ok {
scheme := before
switch { switch {
case strings.EqualFold(scheme, "npipe"): case strings.EqualFold(scheme, "npipe"):
// linux container mount on windows, use the default socket path of the VM / wsl2 // linux container mount on windows, use the default socket path of the VM / wsl2
@@ -247,10 +255,7 @@ func getDockerDaemonSocketMountPath(daemonPath string) string {
// without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job // without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job
// must never write to it. // must never write to it.
func (rc *RunContext) containerDaemonSocket() string { func (rc *RunContext) containerDaemonSocket() string {
if rc.Config.ContainerDaemonSocket == "" { return cmp.Or(rc.Config.ContainerDaemonSocket, "/var/run/docker.sock")
return "/var/run/docker.sock"
}
return rc.Config.ContainerDaemonSocket
} }
const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cache is shared const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cache is shared
@@ -300,10 +305,7 @@ func (rc *RunContext) startDockerProxy(ctx context.Context) {
// 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 != "" { return cmp.Or(rc.GetEnv()["RUNNER_TOOL_CACHE"], fallback)
return path
}
return fallback
} }
// runnerEnv returns a container's RUNNER_* variables, derived from the values runner.tool_cache // runnerEnv returns a container's RUNNER_* variables, derived from the values runner.tool_cache
@@ -336,9 +338,12 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
continue continue
} }
targets[parsed.Target] = true targets[parsed.Target] = true
if parsed.Type == mount.TypeVolume && parsed.Source != "" && !parsed.ReadOnly { switch {
case parsed.Type != mount.TypeVolume || parsed.Source == "" || parsed.ReadOnly:
binds = append(binds, spec)
case mounts[parsed.Source] == "":
mounts[parsed.Source] = parsed.Target mounts[parsed.Source] = parsed.Target
} else { case mounts[parsed.Source] != parsed.Target: // a repeat of the same mount is dropped
binds = append(binds, spec) binds = append(binds, spec)
} }
} }
@@ -396,6 +401,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
randBytes := make([]byte, 8) randBytes := make([]byte, 8)
_, _ = rand.Read(randBytes) _, _ = rand.Read(randBytes)
miscpath := filepath.Join(cacheDir, hex.EncodeToString(randBytes)) miscpath := filepath.Join(cacheDir, hex.EncodeToString(randBytes))
rc.cleanUpJobContainer = func(_ctx context.Context) error { return os.RemoveAll(miscpath) }
actPath := filepath.Join(miscpath, "act") actPath := filepath.Join(miscpath, "act")
if err := os.MkdirAll(actPath, 0o777); err != nil { if err := os.MkdirAll(actPath, 0o777); err != nil {
return err return err
@@ -547,10 +553,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
rc.Env["JOB_CONTAINER_NAME"] = name rc.Env["JOB_CONTAINER_NAME"] = name
delete(rc.jobEnv, "JOB_CONTAINER_NAME") // the runtime name wins over a workflow env of the same name delete(rc.jobEnv, "JOB_CONTAINER_NAME") // the runtime name wins over a workflow env of the same name
envList := make([]string, 0) envList := append(rc.runnerEnv(ctx), "LANG=C.UTF-8") // Use same locale as GitHub Actions
envList = append(envList, rc.runnerEnv(ctx)...)
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
// 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.
@@ -567,12 +570,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: input.Image, container: newContainer(input)}) rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: input.Image, container: newContainer(input)})
} }
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
containerInput := &container.NewContainerInput{ containerInput := &container.NewContainerInput{
Cmd: nil,
Entrypoint: []string{"sleep", strconv.FormatInt(int64(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds()), 10)}, Entrypoint: []string{"sleep", strconv.FormatInt(int64(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds()), 10)},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir), WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: image, Image: image,
@@ -580,7 +579,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
Password: password, Password: password,
Name: name, Name: name,
Env: envList, Env: envList,
NetworkMode: jobContainerNetwork, NetworkMode: networkName,
NetworkAliases: []string{rc.Name}, NetworkAliases: []string{rc.Name},
Stdout: logWriter, Stdout: logWriter,
Stderr: logWriter, Stderr: logWriter,
@@ -672,7 +671,7 @@ func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[
return nil, fmt.Errorf("failed to parse service %s ports: %w", serviceID, err) return nil, fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
} }
serviceLog := common.NewLineWriter(rawLogLine(ctx)) // raw, so a service's output cannot set the job's env, outputs or state serviceLog := common.NewLineWriter(rc.serviceCommandHandler(ctx), rawLogLine(ctx))
inputs[serviceID] = &container.NewContainerInput{ inputs[serviceID] = &container.NewContainerInput{
Name: createContainerName(rc.jobContainerName(), serviceID), Name: createContainerName(rc.jobContainerName(), serviceID),
Image: spec.Image, Image: spec.Image,
@@ -705,6 +704,15 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
return common.NewLineWriter(rc.commandHandler(ctx), rawLogLine(ctx)) return common.NewLineWriter(rc.commandHandler(ctx), rawLogLine(ctx))
} }
// withCommandLogWriter runs executor with a command handler of its own on the job environment's output.
func (rc *RunContext) withCommandLogWriter(ctx context.Context, executor common.Executor) error {
logWriter := rc.commandLogWriter(ctx)
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
defer common.FlushWriter(logWriter) // the last line of output may lack its newline
return executor(ctx)
}
func rawLogLine(ctx context.Context) common.LineHandler { func rawLogLine(ctx context.Context) common.LineHandler {
rawLogger := common.Logger(ctx).With(rawOutputField, true) rawLogger := common.Logger(ctx).With(rawOutputField, true)
return func(line string) bool { return func(line string) bool {
@@ -819,9 +827,7 @@ func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string
cenv := map[string]string{} cenv := map[string]string{}
var cpath string var cpath string
if err := rc.JobContainer.UpdateFromImageEnv(&cenv)(ctx); err == nil { if err := rc.JobContainer.UpdateFromImageEnv(&cenv)(ctx); err == nil {
if p, ok := cenv[path]; ok { cpath = cenv[path]
cpath = p
}
} }
if len(cpath) == 0 { if len(cpath) == 0 {
cpath = rc.JobContainer.DefaultPathVariable() cpath = rc.JobContainer.DefaultPathVariable()
@@ -853,7 +859,7 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
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(line)
} }
} }
if err := s.Err(); err != nil { if err := s.Err(); err != nil {
@@ -862,7 +868,7 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
return nil return nil
} }
func (rc *RunContext) stopJobContainer() common.Executor { func (rc *RunContext) stopContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if rc.cleanUpJobContainer != nil { if rc.cleanUpJobContainer != nil {
return rc.cleanUpJobContainer(ctx) return rc.cleanUpJobContainer(ctx)
@@ -922,7 +928,7 @@ func (rc *RunContext) stopServiceContainers() common.Executor {
const defaultServiceReadyTimeout = 5 * time.Minute const defaultServiceReadyTimeout = 5 * time.Minute
const serviceReadyPollInterval = time.Second const serviceReadyPollInterval = 250 * time.Millisecond
// reportUnstartedServices logs a service that did not start. The steps that need it // reportUnstartedServices logs a service that did not start. The steps that need it
// report it better than the runner can, so the job carries on. // report it better than the runner can, so the job carries on.
@@ -1105,9 +1111,8 @@ func (rc *RunContext) ActionCacheDir() string {
if rc.Config.ActionCacheDir != "" { if rc.Config.ActionCacheDir != "" {
return rc.Config.ActionCacheDir return rc.Config.ActionCacheDir
} }
var xdgCache string xdgCache := os.Getenv("XDG_CACHE_HOME")
var ok bool if xdgCache == "" {
if xdgCache, ok = os.LookupEnv("XDG_CACHE_HOME"); !ok || xdgCache == "" {
if home, err := os.UserHomeDir(); err == nil { if home, err := os.UserHomeDir(); err == nil {
xdgCache = filepath.Join(home, ".cache") xdgCache = filepath.Join(home, ".cache")
} else if xdgCache, err = filepath.Abs("."); err != nil { } else if xdgCache, err = filepath.Abs("."); err != nil {
@@ -1179,10 +1184,6 @@ func (rc *RunContext) IsHostEnv() bool {
return rc.containerSpec.Image == "" && strings.EqualFold(rc.platformImage, "-self-hosted") return rc.containerSpec.Image == "" && strings.EqualFold(rc.platformImage, "-self-hosted")
} }
func (rc *RunContext) stopContainer() common.Executor {
return rc.stopJobContainer()
}
func (rc *RunContext) closeContainer() common.Executor { func (rc *RunContext) closeContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if rc.JobContainer != nil { if rc.JobContainer != nil {
@@ -1404,29 +1405,20 @@ func mergeMaps(maps ...map[string]string) map[string]string {
return rtnMap return rtnMap
} }
var containerNamePattern = regexp.MustCompile("[^a-zA-Z0-9]")
func createContainerName(parts ...string) string { func createContainerName(parts ...string) string {
name := strings.Join(parts, "-") name := strings.Join(parts, "-")
pattern := regexp.MustCompile("[^a-zA-Z0-9]") name = containerNamePattern.ReplaceAllString(name, "-")
name = pattern.ReplaceAllString(name, "-")
name = strings.ReplaceAll(name, "--", "-") name = strings.ReplaceAll(name, "--", "-")
hash := sha256.Sum256([]byte(name)) hash := sha256.Sum256([]byte(name))
// SHA256 is 64 hex characters. So trim name to 63 characters to make room for the hash and separator // SHA256 is 64 hex characters. So trim name to 63 characters to make room for the hash and separator
trimmedName := strings.Trim(trimToLen(name, 63), "-") trimmedName := strings.Trim(name[:min(len(name), 63)], "-")
return fmt.Sprintf("%s-%x", trimmedName, hash) return fmt.Sprintf("%s-%x", trimmedName, hash)
} }
func trimToLen(s string, l int) string {
if l < 0 {
l = 0
}
if len(s) > l {
return s[:l]
}
return s
}
// ownStatus is the result of this context's own steps, which for a composite action is its action status // ownStatus is the result of this context's own steps, which for a composite action is its action status
func (rc *RunContext) ownStatus() string { func (rc *RunContext) ownStatus() string {
if rc.jobFailed { if rc.jobFailed {
@@ -1471,10 +1463,6 @@ func (rc *RunContext) getJobContext() *model.JobContext {
return jobContext return jobContext
} }
func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
return rc.StepResults
}
// getRunnerContext returns the `runner` context: what the execution environment knows // getRunnerContext returns the `runner` context: what the execution environment knows
// (os, arch, temp, tool_cache) plus what only the runner process knows. // (os, arch, temp, tool_cache) plus what only the runner process knows.
func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any { func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
@@ -1508,9 +1496,9 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc := &model.GithubContext{ ghc := &model.GithubContext{
Event: make(map[string]any), Event: make(map[string]any),
Workflow: rc.Run.Workflow.Name, Workflow: rc.Run.Workflow.Name,
RunID: rc.Config.Env["GITHUB_RUN_ID"], RunID: cmp.Or(rc.Config.Env["GITHUB_RUN_ID"], "1"),
RunNumber: rc.Config.Env["GITHUB_RUN_NUMBER"], RunNumber: cmp.Or(rc.Config.Env["GITHUB_RUN_NUMBER"], "1"),
Actor: rc.Config.Actor, Actor: cmp.Or(rc.Config.Actor, "nektos/act"), // for configs that require a default rather than being run as a cmd
EventName: rc.Config.EventName, EventName: rc.Config.EventName,
Action: rc.CurrentStep, Action: rc.CurrentStep,
Token: rc.Config.Token, Token: rc.Config.Token,
@@ -1519,8 +1507,8 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ActionRepository: rc.Env["GITHUB_ACTION_REPOSITORY"], ActionRepository: rc.Env["GITHUB_ACTION_REPOSITORY"],
ActionRef: rc.Env["GITHUB_ACTION_REF"], ActionRef: rc.Env["GITHUB_ACTION_REF"],
RepositoryOwner: rc.Config.Env["GITHUB_REPOSITORY_OWNER"], RepositoryOwner: rc.Config.Env["GITHUB_REPOSITORY_OWNER"],
RetentionDays: rc.Config.Env["GITHUB_RETENTION_DAYS"], RetentionDays: cmp.Or(rc.Config.Env["GITHUB_RETENTION_DAYS"], "0"),
RunnerPerflog: rc.Config.Env["RUNNER_PERFLOG"], RunnerPerflog: cmp.Or(rc.Config.Env["RUNNER_PERFLOG"], "/dev/null"),
RunnerTrackingID: rc.Config.Env["RUNNER_TRACKING_ID"], RunnerTrackingID: rc.Config.Env["RUNNER_TRACKING_ID"],
Repository: rc.Config.Env["GITHUB_REPOSITORY"], Repository: rc.Config.Env["GITHUB_REPOSITORY"],
Ref: rc.Config.Env["GITHUB_REF"], Ref: rc.Config.Env["GITHUB_REF"],
@@ -1535,40 +1523,16 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json" ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json"
} }
if ghc.RunID == "" { if preset := rc.Config.PresetGitHubContext; preset != nil {
ghc.RunID = "1" merged := *preset
} merged.Workflow, merged.Job, merged.Workspace, merged.EventPath = ghc.Workflow, ghc.Job, ghc.Workspace, ghc.EventPath
merged.Action, merged.ActionPath, merged.ActionRepository, merged.ActionRef = ghc.Action, ghc.ActionPath, ghc.ActionRepository, ghc.ActionRef
merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
*ghc = merged
if ghc.RunNumber == "" { rc.setInstanceURLs(ghc)
ghc.RunNumber = "1" ghc.GraphQLURL = "" // Gitea doesn't support graphql
} return ghc
if ghc.RetentionDays == "" {
ghc.RetentionDays = "0"
}
if ghc.RunnerPerflog == "" {
ghc.RunnerPerflog = "/dev/null"
}
// Backwards compatibility for configs that require
// a default rather than being run as a cmd
if ghc.Actor == "" {
ghc.Actor = "nektos/act"
}
{ // Adapt to Gitea
if preset := rc.Config.PresetGitHubContext; preset != nil {
merged := *preset
merged.Workflow, merged.Job, merged.Workspace, merged.EventPath = ghc.Workflow, ghc.Job, ghc.Workspace, ghc.EventPath
merged.Action, merged.ActionPath, merged.ActionRepository, merged.ActionRef = ghc.Action, ghc.ActionPath, ghc.ActionRepository, ghc.ActionRef
merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
*ghc = merged
rc.setInstanceURLs(ghc)
ghc.GraphQLURL = "" // Gitea doesn't support graphql
return ghc
}
} }
if rc.EventJSON != "" { if rc.EventJSON != "" {
@@ -1580,6 +1544,7 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc.SetBaseAndHeadRef() ghc.SetBaseAndHeadRef()
repoPath := rc.Config.Workdir repoPath := rc.Config.Workdir
ctx = ghcontext.WithPinnedCheckout(ctx, &rc.checkoutPins)
ghcontext.SetRepositoryAndOwner(ctx, ghc, repoPath) ghcontext.SetRepositoryAndOwner(ctx, ghc, repoPath)
if ghc.Ref == "" { if ghc.Ref == "" {
ghcontext.SetRef(ctx, ghc, repoPath) ghcontext.SetRef(ctx, ghc, repoPath)
@@ -1621,10 +1586,6 @@ func withHTTPS(u string) string {
} }
func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool { func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
if step.Type() == model.StepTypeInvalid {
// This will be errored out by the executor later, we need this here to avoid a null panic though
return false
}
if step.Type() != model.StepTypeUsesActionRemote { if step.Type() != model.StepTypeUsesActionRemote {
return false return false
} }
@@ -1642,7 +1603,9 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
} }
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) { func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
env["CI"] = "true" if _, ok := env["CI"]; !ok {
env["CI"] = "true"
}
env["GITHUB_WORKFLOW"] = github.Workflow env["GITHUB_WORKFLOW"] = github.Workflow
env["GITHUB_RUN_ID"] = github.RunID env["GITHUB_RUN_ID"] = github.RunID
env["GITHUB_RUN_NUMBER"] = github.RunNumber env["GITHUB_RUN_NUMBER"] = github.RunNumber
@@ -1660,6 +1623,7 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
env["GITHUB_REF"] = github.Ref env["GITHUB_REF"] = github.Ref
env["GITHUB_REF_NAME"] = github.RefName env["GITHUB_REF_NAME"] = github.RefName
env["GITHUB_REF_TYPE"] = github.RefType env["GITHUB_REF_TYPE"] = github.RefType
env["GITHUB_REF_PROTECTED"] = strconv.FormatBool(github.RefProtected)
env["GITHUB_JOB"] = github.Job env["GITHUB_JOB"] = github.Job
env["GITHUB_REPOSITORY_OWNER"] = github.RepositoryOwner env["GITHUB_REPOSITORY_OWNER"] = github.RepositoryOwner
env["GITHUB_RETENTION_DAYS"] = github.RetentionDays env["GITHUB_RETENTION_DAYS"] = github.RetentionDays
@@ -1669,6 +1633,18 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
env["GITHUB_HEAD_REF"] = github.HeadRef env["GITHUB_HEAD_REF"] = github.HeadRef
env["GITHUB_SERVER_URL"] = github.ServerURL env["GITHUB_SERVER_URL"] = github.ServerURL
env["GITHUB_API_URL"] = github.APIURL env["GITHUB_API_URL"] = github.APIURL
for name, value := range map[string]string{
"GITHUB_WORKFLOW_REF": github.WorkflowRef,
"GITHUB_WORKFLOW_SHA": github.WorkflowSha,
"GITHUB_ACTOR_ID": github.ActorID,
"GITHUB_TRIGGERING_ACTOR": github.TriggeringActor,
"GITHUB_REPOSITORY_ID": github.RepositoryID,
"GITHUB_REPOSITORY_OWNER_ID": github.RepositoryOwnerID,
} {
if value != "" { // a context without them keeps what the environment set
env[name] = value
}
}
for _, name := range []string{"GITHUB_SERVER_URL", "GITHUB_API_URL"} { for _, name := range []string{"GITHUB_SERVER_URL", "GITHUB_API_URL"} {
if env[name] == "" { if env[name] == "" {
@@ -1689,7 +1665,8 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
} }
if rc.Config.ArtifactServerPath != "" { if rc.Config.ArtifactServerPath != "" {
setActionRuntimeVars(rc, env) env["ACTIONS_RUNTIME_URL"] = cmp.Or(os.Getenv("ACTIONS_RUNTIME_URL"), "http://"+net.JoinHostPort(rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort)+"/")
env["ACTIONS_RUNTIME_TOKEN"] = cmp.Or(rc.runtimeToken, os.Getenv("ACTIONS_RUNTIME_TOKEN"), "token")
} }
if imageOS := rc.imageOS(ctx); imageOS != "" { if imageOS := rc.imageOS(ctx); imageOS != "" {
@@ -1744,20 +1721,6 @@ func imageOSFromImage(image string) string {
return "" return ""
} }
func setActionRuntimeVars(rc *RunContext, env map[string]string) {
actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL")
if actionsRuntimeURL == "" {
actionsRuntimeURL = "http://" + net.JoinHostPort(rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort) + "/"
}
env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL
actionsRuntimeToken := os.Getenv("ACTIONS_RUNTIME_TOKEN")
if actionsRuntimeToken == "" {
actionsRuntimeToken = "token"
}
env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken
}
func registryCredentials(credentials map[string]string, prefix string) (string, string, error) { func registryCredentials(credentials map[string]string, prefix string) (string, string, error) {
if credentials == nil { if credentials == nil {
return "", "", nil return "", "", nil
File diff suppressed because it is too large. Load diff
+14 -18
View File
@@ -41,6 +41,7 @@ type Config struct {
Env map[string]string // env for containers Env map[string]string // env for containers
Secrets map[string]string // list of secrets Secrets map[string]string // list of secrets
ExtraMasks []string // values to hide that are not in Secrets, such as the proxy password ExtraMasks []string // values to hide that are not in Secrets, such as the proxy password
RuntimeTokens map[*model.Workflow]string // ACTIONS_RUNTIME_TOKEN per workflow run, as each is its own artifact run
Vars map[string]string // list of vars Vars map[string]string // list of vars
Token string // GitHub token Token string // GitHub token
InsecureSecrets bool // switch hiding output when printing to terminal InsecureSecrets bool // switch hiding output when printing to terminal
@@ -94,20 +95,14 @@ type Config struct {
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
} }
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and // RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and RUNNER_DEBUG.
// RUNNER_DEBUG. Only the secret also makes the reporter keep ::debug:: output, the env
// is accepted for `exec` and for runners configured with it.
func (c Config) RunnerDebug() bool { func (c Config) RunnerDebug() bool {
return c.Secrets["ACTIONS_STEP_DEBUG"] == "true" || c.Env["ACTIONS_STEP_DEBUG"] == "true" return c.Secrets["ACTIONS_STEP_DEBUG"] == "true" || c.Env["ACTIONS_STEP_DEBUG"] == "true"
} }
// GetToken: Adapt to Gitea // GetToken: Adapt to Gitea
func (c Config) GetToken() string { func (c Config) GetToken() string {
token := c.Secrets["GITHUB_TOKEN"] return cmp.Or(c.Secrets["GITEA_TOKEN"], c.Secrets["GITHUB_TOKEN"])
if c.Secrets["GITEA_TOKEN"] != "" {
token = c.Secrets["GITEA_TOKEN"]
}
return token
} }
// DefaultActionURL returns the host used for implicit remote actions. // DefaultActionURL returns the host used for implicit remote actions.
@@ -187,8 +182,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
stagePipeline := make([]common.Executor, 0) stagePipeline := make([]common.Executor, 0)
logging.Debugf("Plan Stages: %v", plan.Stages) logging.Debugf("Plan Stages: %v", plan.Stages)
for i := range plan.Stages { for _, stage := range plan.Stages {
stage := plan.Stages[i]
stagePipeline = append(stagePipeline, func(ctx context.Context) error { stagePipeline = append(stagePipeline, func(ctx context.Context) error {
pipeline := make([]common.Executor, 0) pipeline := make([]common.Executor, 0)
for _, run := range stage.Runs { for _, run := range stage.Runs {
@@ -270,9 +264,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
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)
} }
if len(rc.String()) > maxJobNameLen { maxJobNameLen = max(maxJobNameLen, len(rc.String()))
maxJobNameLen = len(rc.String())
}
if rc.caller != nil { // For Gitea if rc.caller != nil { // For Gitea
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "pending") rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "pending")
} }
@@ -324,10 +316,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
logging.Debugf("Using CPU count for parallelism: %d", parallelism) logging.Debugf("Using CPU count for parallelism: %d", parallelism)
} }
// Don't exceed the number of pipeline elements parallelism = min(parallelism, len(pipeline))
if parallelism > len(pipeline) {
parallelism = len(pipeline)
}
logging.Infof("Executing %d pipeline elements with parallelism %d", len(pipeline), parallelism) logging.Infof("Executing %d pipeline elements with parallelism %d", len(pipeline), parallelism)
return common.NewParallelExecutor(parallelism, pipeline...)(ctx) return common.NewParallelExecutor(parallelism, pipeline...)(ctx)
@@ -335,7 +324,9 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
} }
executor := common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan)) executor := common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
return func(ctx context.Context) error { return executor(ghcontext.WithPinnedCheckout(ctx)) } return func(ctx context.Context) error {
return executor(container.WithRunnerArch(ghcontext.WithPinnedCheckout(ctx, &sync.Map{})))
}
} }
func handleFailure(plan *model.Plan) common.Executor { func handleFailure(plan *model.Plan) common.Executor {
@@ -365,6 +356,11 @@ func (runner *runnerImpl) newCombinationRunContext(ctx context.Context, run *mod
caller: runner.caller, caller: runner.caller,
jobIndex: index, jobIndex: index,
jobTotal: total, jobTotal: total,
runtimeToken: runner.config.RuntimeTokens[run.Workflow],
}
if runner.caller != nil {
rc.runtimeToken = runner.caller.runContext.runtimeToken
} }
if err := rc.resolveWorkflowCall(ctx); err != nil { if err := rc.resolveWorkflowCall(ctx); err != nil {
return nil, err return nil, err
+14 -9
View File
@@ -13,11 +13,13 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/ghcontext"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@@ -26,12 +28,13 @@ import (
) )
var ( var (
baseImage = "node:24-bookworm-slim" baseImage = "node:24-bookworm-slim"
platforms map[string]string platforms map[string]string
logLevel = logging.LevelDebug logLevel = logging.LevelDebug
workdir = "testdata" workdir = "testdata"
secrets map[string]string secrets map[string]string
testRunID = strings.ToLower(rand.Text()[:8]) // keeps docker names apart from concurrent test processes on one daemon testRunID = strings.ToLower(rand.Text()[:8]) // keeps docker names apart from concurrent test processes on one daemon
testCheckoutPins sync.Map
) )
func mapPlatformPicker(platforms map[string]string) func([]string) string { func mapPlatformPicker(platforms map[string]string) func([]string) string {
@@ -158,12 +161,12 @@ func TestNewPlanExecutorRequiresRunsOnBeforeRunningAnyJob(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
plan, err := model.CombineWorkflowPlanner(workflow).PlanJob("test") plan, err := model.CombineWorkflowPlanner(workflow).PlanJob("test")
require.NoError(t, err) require.NoError(t, err)
require.ErrorContains(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(plan)(t.Context()), `job "test" requires a non-empty 'runs-on'`, runsOn) require.ErrorContains(t, (&runnerImpl{config: &Config{Env: testCheckoutEnv()}}).NewPlanExecutor(plan)(t.Context()), `job "test" requires a non-empty 'runs-on'`, runsOn)
assert.Empty(t, workflow.Jobs["first"].Result, runsOn) assert.Empty(t, workflow.Jobs["first"].Result, runsOn)
} }
workflow := &model.Workflow{Jobs: map[string]*model.Job{"build": {Result: "success"}}} workflow := &model.Workflow{Jobs: map[string]*model.Job{"build": {Result: "success"}}}
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "build"}}}}})(t.Context())) require.NoError(t, (&runnerImpl{config: &Config{Env: testCheckoutEnv()}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "build"}}}}})(t.Context()))
assert.Equal(t, "success", workflow.Jobs["build"].Result) assert.Equal(t, "success", workflow.Jobs["build"].Result)
} }
@@ -219,6 +222,7 @@ type TestJobFileInfo struct {
} }
func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config) { func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config) {
ctx = ghcontext.WithPinnedCheckout(ctx, &testCheckoutPins)
fmt.Printf("::group::%s\n", j.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act fmt.Printf("::group::%s\n", j.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
logging.SetLevel(logLevel) logging.SetLevel(logLevel)
@@ -456,6 +460,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets}, {workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
{workdir, "set-env-step-env-override", "push", "", platforms, secrets}, {workdir, "set-env-step-env-override", "push", "", platforms, secrets},
{workdir, "set-env-new-env-file-per-step", "push", "", platforms, secrets}, {workdir, "set-env-new-env-file-per-step", "push", "", platforms, secrets},
{workdir, "github-parity", "push", "", platforms, secrets},
{workdir, "no-panic-on-invalid-composite-action", "push", "jobs failed due to invalid action", platforms, secrets}, {workdir, "no-panic-on-invalid-composite-action", "push", "jobs failed due to invalid action", platforms, secrets},
}...) }...)
} }
@@ -480,7 +485,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
} }
func TestDryrunEvent(t *testing.T) { func TestDryrunEvent(t *testing.T) {
t.Parallel() t.Setenv("DOCKER_HOST", "://")
// Dryrun plans without containers or network (shells and local actions only). // Dryrun plans without containers or network (shells and local actions only).
ctx := common.WithDryrun(t.Context(), true) ctx := common.WithDryrun(t.Context(), true)
+45 -48
View File
@@ -165,27 +165,21 @@ 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) initialFiles := files
if !topRC.summaryFileInitialized[stepSummaryIndex] { if !topRC.summaryFileInitialized[stepSummaryIndex] {
_ = rc.JobContainer.Copy(actPath, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})(ctx) initialFiles = append([]*container.FileEntry{{Name: summaryFileCommand, Mode: 0o666}}, files...)
topRC.summaryFileInitialized[stepSummaryIndex] = true topRC.summaryFileInitialized[stepSummaryIndex] = true
} }
fileCommandInitErr := rc.JobContainer.Copy(actPath, initialFiles...)(ctx)
if fileCommandInitErr != nil && len(initialFiles) > len(files) {
fileCommandInitErr = rc.JobContainer.Copy(actPath, files...)(ctx) // a broken summary must not block the resets
}
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS. timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, step)
// Cloned: the step executor keeps writing to its own env map after this point, on a
// different goroutine from the command handler that reads it.
rc.setCurrentStepEnv(maps0.Clone(*step.getEnv()))
defer rc.setCurrentStepEnv(nil)
_ = rc.takeUnsecureCommandError() // a refusal from before any step belongs to no step
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
defer cancelTimeOut() defer cancelTimeOut()
err = executor(timeoutctx) err = fileCommandInitErr
// Always take it, so the job-scoped error cannot leak onto a later step. A refusal
// fails the step as it does on GitHub, but the executor's own error wins.
insecureErr := rc.takeUnsecureCommandError()
if err == nil { if err == nil {
err = insecureErr err = rc.runWithCommands(timeoutctx, *step.getEnv(), executor)
} }
fileCommandCtx := ctx fileCommandCtx := ctx
if ctx.Err() != nil { // an interrupted step still records its file commands, a live deadline stays in force if ctx.Err() != nil { // an interrupted step still records its file commands, a live deadline stays in force
@@ -193,17 +187,19 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
fileCommandCtx, cancelFileCommands = context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout) fileCommandCtx, cancelFileCommands = context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
defer cancelFileCommands() defer cancelFileCommands()
} }
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil { if fileCommandInitErr == nil {
err = fileErr if fileErr := processRunnerEnvFileCommand(fileCommandCtx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
} err = fileErr
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil { }
err = fileErr if fileErr := processRunnerEnvFileCommand(fileCommandCtx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
} err = fileErr
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil { }
err = fileErr if fileErr := processRunnerEnvFileCommand(fileCommandCtx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
} err = fileErr
if fileErr := rc.UpdateExtraPath(fileCommandCtx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil { }
err = fileErr if fileErr := rc.UpdateExtraPath(fileCommandCtx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
err = fileErr
}
} }
_ = rc.JobContainer.Copy(actPath, files...)(fileCommandCtx) _ = rc.JobContainer.Copy(actPath, files...)(fileCommandCtx)
@@ -212,7 +208,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
} 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)
if parseErr != nil { if parseErr != nil {
stepResult.Conclusion = model.StepStatusFailure stepResult.Conclusion = model.StepStatusFailure
logger.With("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString) logger.With("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
@@ -236,8 +232,11 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
} }
} }
func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) { func evaluateStepTimeout(ctx context.Context, step step) (context.Context, context.CancelFunc) {
timeout, err := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes) if step.getStepModel().TimeoutMinutes == "" {
return ctx, func() {}
}
timeout, err := step.getRunContext().NewStepExpressionEvaluator(ctx, step).Interpolate(ctx, step.getStepModel().TimeoutMinutes)
if err != nil { if err != nil {
common.Logger(ctx).Errorf("An error occurred when attempting to determine the step timeout: %s", err) common.Logger(ctx).Errorf("An error occurred when attempting to determine the step timeout: %s", err)
} else if timeout != "" { } else if timeout != "" {
@@ -251,14 +250,23 @@ func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, ste
func setupEnv(ctx context.Context, step step) error { func setupEnv(ctx context.Context, step step) error {
rc := step.getRunContext() rc := step.getRunContext()
mergeEnv(ctx, step) env := step.getEnv()
// container env is the image's baseline, which job env and $GITHUB_ENV override
mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
var err error var err error
exprEval := rc.NewExpressionEvaluator(ctx) exprEval := rc.NewExpressionEvaluator(ctx)
stepEnv := step.getStepModel().Environment() stepEnv := step.getStepModel().Environment()
for k, v := range stepEnv { if envNode := step.getStepModel().Env; strings.Contains(envNode.Value, "${{") { // only a whole-map expression has a value
if stepEnv[k], err = exprEval.Interpolate(ctx, v); err != nil { if err := model.DecodeEvaluated("step env", envNode, exprEval.shared(ctx).EvaluateYamlNode, &stepEnv); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err) return err
}
} else {
for k, v := range stepEnv {
if stepEnv[k], err = exprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
}
} }
} }
// merge step env last, since it should not be overwritten // merge step env last, since it should not be overwritten
@@ -279,16 +287,6 @@ func setupInputs(ctx context.Context, step step) error {
return nil return nil
} }
func mergeEnv(ctx context.Context, step step) {
env := step.getEnv()
rc := step.getRunContext()
// container env is the image's baseline, which job env and $GITHUB_ENV override
mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
}
func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage) (bool, error) { func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage) (bool, error) {
rc := step.getRunContext() rc := step.getRunContext()
@@ -312,9 +310,9 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage)
return runStep, nil return runStep, nil
} }
func isContinueOnError(ctx context.Context, expr string, step step, _ stepStage) (bool, error) { func isContinueOnError(ctx context.Context, expr string, step step) (bool, error) {
// https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=true#L962 // https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=true#L962
if len(strings.TrimSpace(expr)) == 0 { if strings.TrimSpace(expr) == "" {
return false, nil return false, nil
} }
@@ -352,7 +350,7 @@ func mergeIntoMapCaseInsensitive(target map[string]string, maps ...map[string]st
if k, ok := foldKeys[foldKey]; ok { if k, ok := foldKeys[foldKey]; ok {
return k return k
} }
foldKeys[strings.ToLower(foldKey)] = s foldKeys[foldKey] = s
return s return s
} }
for _, m := range maps { for _, m := range maps {
@@ -363,8 +361,7 @@ func mergeIntoMapCaseInsensitive(target map[string]string, maps ...map[string]st
} }
func symlinkJoin(filename, sym, parent string) (string, error) { func symlinkJoin(filename, sym, parent string) (string, error) {
dir := path.Dir(filename) dest := path.Join(path.Dir(filename), sym)
dest := path.Join(dir, sym)
prefix := path.Clean(parent) + "/" prefix := path.Clean(parent) + "/"
if strings.HasPrefix(dest, prefix) || prefix == "./" { if strings.HasPrefix(dest, prefix) || prefix == "./" {
return dest, nil return dest, nil
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more