mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 08:54:21 +02:00
Compare commits
131 Commits
v1.0.2
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9d47fb5ed | ||
|
|
34bfa19150 | ||
|
|
96d9f491db | ||
|
|
14ec00b66e | ||
|
|
68c6a5b4f1 | ||
|
|
0cd0e52a24 | ||
|
|
47d5b5ad03 | ||
|
|
2398d4a527 | ||
|
|
b7a3bf98bc | ||
|
|
da4037899a | ||
|
|
e4fe49dba4 | ||
|
|
41c72216bf | ||
|
|
3f7fd16ea1 | ||
|
|
0192861155 | ||
|
|
e6c7ba3a15 | ||
|
|
61f0cfa951 | ||
|
|
fc0e03e5a9 | ||
|
|
333eb17d19 | ||
|
|
c3b39e0d99 | ||
|
|
78a74f78f8 | ||
|
|
8c519ce318 | ||
|
|
de43c84203 | ||
|
|
3c5ef1721a | ||
|
|
94ab020204 | ||
|
|
b4a64b97dd | ||
|
|
26f9fb12af | ||
|
|
c9c4957e38 | ||
|
|
b1a02cdd5d | ||
|
|
0fd8602ac3 | ||
|
|
6133d64270 | ||
|
|
c43cbe87ca | ||
|
|
7bec310002 | ||
|
|
8af385d147 | ||
|
|
46f22c78d2 | ||
|
|
068afc3996 | ||
|
|
0e8896c52a | ||
|
|
89467c9dd0 | ||
|
|
0c08b0f2da | ||
|
|
aa7a29a157 | ||
|
|
ad967330a8 | ||
|
|
60177008a5 | ||
|
|
58c5eb8d21 | ||
|
|
d6882b3df5 | ||
|
|
7e7e3ef1a6 | ||
|
|
16357a34b2 | ||
|
|
d53538ac38 | ||
|
|
554b3b7671 | ||
|
|
65756d60b3 | ||
|
|
be9b4502d6 | ||
|
|
1d74ae636a | ||
|
|
b12d02c25f | ||
|
|
f2e0cf9131 | ||
|
|
0ee4643d4a | ||
|
|
e774003c18 | ||
|
|
eeb479ea89 | ||
|
|
eba33e178d | ||
|
|
3396021e0f | ||
|
|
745b0ab6e4 | ||
|
|
b7f6b6d90a | ||
|
|
cdcea87a45 | ||
|
|
3c4bcf3ebf | ||
|
|
e22d3fa263 | ||
|
|
99bc50d538 | ||
|
|
8f72c60afa | ||
|
|
4e7fd1c68a | ||
|
|
bd41a367fe | ||
|
|
c566013db4 | ||
|
|
40e021309a | ||
|
|
d3b3519dea | ||
|
|
6bdcb54828 | ||
|
|
007717956a | ||
|
|
df0370f8bf | ||
|
|
5f0636faad | ||
|
|
4997f33b5f | ||
|
|
2963716953 | ||
|
|
3996d6d032 | ||
|
|
205af7cd01 | ||
|
|
33e6d1d8ff | ||
|
|
56979e6ab8 | ||
|
|
bf99e6a758 | ||
|
|
740a3d4db4 | ||
|
|
822af5029f | ||
|
|
526c46b485 | ||
|
|
355289bc54 | ||
|
|
e583b0706b | ||
|
|
8ad84cd96a | ||
|
|
0a2f28244d | ||
|
|
443b0e336c | ||
|
|
53c4db6a4b | ||
|
|
1073c8bfec | ||
|
|
ff7d9ca8d0 | ||
|
|
984b47c716 | ||
|
|
c749e52bb7 | ||
|
|
f17b6b9fc3 | ||
|
|
c7c4bd600a | ||
|
|
abec931d98 | ||
|
|
270ea41232 | ||
|
|
0b9f251b6a | ||
|
|
273f6b4247 | ||
|
|
47ee45412a | ||
|
|
38b69bb214 | ||
|
|
1c62c0635f | ||
|
|
0e0c54b272 | ||
|
|
d6fbe75721 | ||
|
|
b30204aa94 | ||
|
|
7b5ebe9618 | ||
|
|
4317662a38 | ||
|
|
2208e7ec63 | ||
|
|
fab9714f9a | ||
|
|
10475db58a | ||
|
|
9e738c203c | ||
|
|
6023928876 | ||
|
|
014ce438c1 | ||
|
|
cf7e29c10d | ||
|
|
8a99506fed | ||
|
|
5873b8b054 | ||
|
|
5464d33eef | ||
|
|
3c5f03ff8f | ||
|
|
880e9755d9 | ||
|
|
8d7cf48a6f | ||
|
|
f23605c614 | ||
|
|
00b7fec80f | ||
|
|
dda5841af8 | ||
|
|
32bed52686 | ||
|
|
a7e972d8de | ||
|
|
763b38ece3 | ||
|
|
a1f13cb970 | ||
|
|
1e3ab0c40a | ||
|
|
295eecb9af | ||
|
|
ef6ca957b5 | ||
|
|
8088df52b9 |
27
.gitea/workflows/pull-pr-title.yml
Normal file
27
.gitea/workflows/pull-pr-title.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: pr-title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- run: make lint-pr-title
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
@@ -17,14 +17,26 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v6
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
args: release --nightly
|
||||
@@ -35,6 +47,10 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
GORELEASER_FORCE_TOKEN: "gitea"
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -43,27 +59,33 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
variant:
|
||||
# The basic image is built from source and can target any arch the
|
||||
# toolchain supports. The dind variants are limited to the arches the
|
||||
# docker:dind base image publishes.
|
||||
- target: basic
|
||||
tag_suffix: ""
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
|
||||
- target: dind
|
||||
tag_suffix: "-dind"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- target: dind-rootless
|
||||
tag_suffix: "-dind-rootless"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -71,15 +93,20 @@ jobs:
|
||||
- name: Echo the tag
|
||||
run: echo "${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}"
|
||||
|
||||
- name: Get Meta
|
||||
id: meta
|
||||
run: |
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.variant.target }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
platforms: ${{ matrix.variant.platforms }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.REPO_VERSION }}
|
||||
|
||||
@@ -9,21 +9,33 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
- uses: actions/setup-go@v6
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Import GPG key
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@v7
|
||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.PASSPHRASE }}
|
||||
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
args: release
|
||||
@@ -34,6 +46,10 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
GORELEASER_FORCE_TOKEN: "gitea"
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||
@@ -42,12 +58,18 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
variant:
|
||||
# The basic image is built from source and can target any arch the
|
||||
# toolchain supports. The dind variants are limited to the arches the
|
||||
# docker:dind base image publishes.
|
||||
- target: basic
|
||||
tag_suffix: ""
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64,linux/s390x
|
||||
- target: dind
|
||||
tag_suffix: "-dind"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- target: dind-rootless
|
||||
tag_suffix: "-dind-rootless"
|
||||
platforms: linux/amd64,linux/arm64
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
@@ -55,25 +77,25 @@ jobs:
|
||||
DOCKER_LATEST: latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: "Docker meta"
|
||||
id: docker_meta
|
||||
uses: https://github.com/docker/metadata-action@v5
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_ORG }}/runner
|
||||
@@ -86,13 +108,13 @@ jobs:
|
||||
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.variant.target }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
platforms: ${{ matrix.variant.platforms }}
|
||||
push: true
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.docker_meta.outputs.version }}
|
||||
|
||||
@@ -9,14 +9,42 @@ jobs:
|
||||
lint:
|
||||
name: check and test
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# The runner image ships a stale docker.io login; point docker at an empty config so
|
||||
# image pulls go straight to anonymous instead of attempting (and failing) that auth
|
||||
# first. The path must be a literal: the `runner` context is unavailable in job-level
|
||||
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
|
||||
# to ~/.docker with the stale credentials.
|
||||
DOCKER_CONFIG: /tmp/docker-noauth
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: prepare anonymous docker config
|
||||
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
||||
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
|
||||
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
|
||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||
- name: pre-pull test images
|
||||
run: |
|
||||
for img in node:24-bookworm-slim nginx:alpine; do
|
||||
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
|
||||
done
|
||||
- name: lint
|
||||
run: make lint
|
||||
- name: checks
|
||||
run: make checks
|
||||
- name: build
|
||||
run: make build
|
||||
- name: test
|
||||
run: make test
|
||||
run: make test
|
||||
# Build the dind image and run the daemon-facing tests against the docker version it
|
||||
# ships, catching daemon-level regressions (e.g. gitea/runner#981) before release. Runs
|
||||
# after `make test` so the images it needs are already present on the host daemon.
|
||||
- name: test against dind image
|
||||
run: make test-dind
|
||||
- name: coverage report
|
||||
run: |
|
||||
make coverage-report
|
||||
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,7 +1,10 @@
|
||||
/gitea-runner
|
||||
.env
|
||||
!/act/runner/testdata/secrets/.env
|
||||
.runner
|
||||
.runner.lock
|
||||
coverage.txt
|
||||
.tmp/
|
||||
/config.yaml
|
||||
|
||||
# Jetbrains
|
||||
@@ -10,4 +13,5 @@ coverage.txt
|
||||
.vscode
|
||||
__debug_bin
|
||||
# gorelease binary folder
|
||||
dist
|
||||
/dist
|
||||
.DS_Store
|
||||
@@ -11,6 +11,7 @@ linters:
|
||||
- dupl
|
||||
- errcheck
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- gocheckcompilerdirectives
|
||||
- gocritic
|
||||
- goheader
|
||||
@@ -102,6 +103,9 @@ linters:
|
||||
- linters:
|
||||
- forbidigo
|
||||
path: cmd
|
||||
- linters:
|
||||
- forcetypeassert
|
||||
path: _test\.go
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
@@ -93,6 +93,37 @@ blobs:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
|
||||
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
||||
# parallel S3+R2 period (S3 will be removed once migration completes).
|
||||
# A second `blobs:` entry is impossible here since the blob pipe
|
||||
# authenticates from the global AWS_* env with no per-entry
|
||||
# credentials; `publishers:` supports per-entry `env:` instead, so
|
||||
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
||||
# publishers inherit almost nothing from the environment, hence the
|
||||
# explicit R2_* forwarding below.
|
||||
#
|
||||
# This publisher fires 109 times for 73 distinct keys because
|
||||
# goreleaser's release pipe already registers `release.extra_files`
|
||||
# as UploadableFile artifacts, and `internal/exec`'s filterArtifacts
|
||||
# appends this block's own extra_files with no de-duplication. It
|
||||
# can't be globbed away, since gobwas/glob (via goreleaser/fileglob)
|
||||
# has no substring-exclusion matcher. It's harmless: PUT is
|
||||
# idempotent, and the `./**.xz` glob below is kept deliberately so
|
||||
# this publisher declares its own complete file set rather than
|
||||
# implicitly depending on the `release:` block's globs.
|
||||
publishers:
|
||||
- name: cloudflare-r2
|
||||
checksum: true
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
cmd: sh scripts/upload-r2.sh {{ abs .ArtifactPath }} gitea-runner/{{ .Version }}/{{ .ArtifactName }}
|
||||
env:
|
||||
- R2_ENDPOINT={{ index .Env "R2_ENDPOINT" }}
|
||||
- R2_BUCKET={{ index .Env "R2_BUCKET" }}
|
||||
- R2_ACCESS_KEY_ID={{ index .Env "R2_ACCESS_KEY_ID" }}
|
||||
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||
|
||||
archives:
|
||||
- format: binary
|
||||
name_template: "{{ .Binary }}"
|
||||
|
||||
28
Dockerfile
28
Dockerfile
@@ -1,7 +1,7 @@
|
||||
### BUILDER STAGE
|
||||
#
|
||||
#
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM golang:1.26-alpine3.23 AS builder
|
||||
|
||||
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
||||
RUN apk add --no-cache make git
|
||||
@@ -17,9 +17,14 @@ RUN make clean && make build
|
||||
### DIND VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29-dind AS dind
|
||||
FROM docker:29.6.2-dind AS dind
|
||||
|
||||
RUN apk add --no-cache s6 bash git tzdata
|
||||
ARG VERSION=dev
|
||||
|
||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||
LABEL org.opencontainers.image.version="${VERSION}"
|
||||
|
||||
RUN apk add --no-cache s6 bash git tzdata nftables
|
||||
|
||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
@@ -32,10 +37,15 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### DIND-ROOTLESS VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29-dind-rootless AS dind-rootless
|
||||
FROM docker:29.6.2-dind-rootless AS dind-rootless
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||
LABEL org.opencontainers.image.version="${VERSION}"
|
||||
|
||||
USER root
|
||||
RUN apk add --no-cache s6 bash git tzdata
|
||||
RUN apk add --no-cache s6 bash git tzdata nftables
|
||||
|
||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||
@@ -53,7 +63,13 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### BASIC VARIANT
|
||||
#
|
||||
#
|
||||
FROM alpine AS basic
|
||||
FROM alpine:3.24 AS basic
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||
LABEL org.opencontainers.image.version="${VERSION}"
|
||||
|
||||
RUN apk add --no-cache tini bash git tzdata
|
||||
|
||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||
|
||||
47
Makefile
47
Makefile
@@ -4,9 +4,9 @@ DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
||||
GO ?= go
|
||||
SHASUM ?= shasum -a 256
|
||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
||||
XGO_VERSION := go-1.26.x
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.10
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
|
||||
|
||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||
@@ -18,8 +18,10 @@ DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
|
||||
|
||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||
|
||||
STATIC ?=
|
||||
EXTLDFLAGS ?=
|
||||
@@ -38,12 +40,15 @@ endif
|
||||
ifeq ($(OS), Windows_NT)
|
||||
GOFLAGS := -v -buildmode=exe
|
||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||
else ifeq ($(OS), Windows)
|
||||
GOFLAGS := -v -buildmode=exe
|
||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||
else
|
||||
GOFLAGS := -v
|
||||
EXECUTABLE ?= $(EXECUTABLE)
|
||||
GO_ENV_WINDOWS := GOOS=windows
|
||||
endif
|
||||
|
||||
STORED_VERSION_FILE := VERSION
|
||||
@@ -92,7 +97,7 @@ go-check:
|
||||
|
||||
.PHONY: fmt-check
|
||||
fmt-check: fmt
|
||||
@diff=$$(git diff --color=always); \
|
||||
@diff=$$(git diff --color=always -- '*.go'); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Please run 'make fmt' and commit the result:"; \
|
||||
printf "%s" "$${diff}"; \
|
||||
@@ -107,19 +112,31 @@ deps-tools: ## install tool dependencies
|
||||
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
||||
wait
|
||||
|
||||
.PHONY: checks
|
||||
checks: tidy-check fmt-check security-check ## run the non-lint source checks
|
||||
|
||||
.PHONY: lint
|
||||
lint: lint-go ## lint everything
|
||||
lint: lint-go lint-go-windows ## lint everything
|
||||
|
||||
.PHONY: lint-go
|
||||
lint-go: ## lint go files
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
||||
|
||||
.PHONY: lint-go-windows
|
||||
lint-go-windows: ## lint Windows go files
|
||||
$(GO) install $(GOLANGCI_LINT_PACKAGE)
|
||||
$(GO_ENV_WINDOWS) golangci-lint run
|
||||
|
||||
.PHONY: lint-go-fix
|
||||
lint-go-fix: ## lint go files and fix issues
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
||||
|
||||
.PHONY: lint-pr-title
|
||||
lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
||||
@node ./tools/lint-pr-title.ts
|
||||
|
||||
.PHONY: security-check
|
||||
security-check: deps-tools
|
||||
security-check:
|
||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||
|
||||
.PHONY: tidy
|
||||
@@ -136,8 +153,18 @@ tidy-check: tidy
|
||||
fi
|
||||
|
||||
.PHONY: test
|
||||
test: fmt-check security-check ## test everything
|
||||
@$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
test: ## test everything (integration tests self-skip without docker/network)
|
||||
@$(GO) test $(GOTEST_FLAGS) -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
|
||||
.PHONY: coverage-report
|
||||
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||
@mkdir -p .tmp
|
||||
@node ./tools/coverage-report.ts -i coverage.txt -o .tmp/coverage.md
|
||||
@echo "Wrote .tmp/coverage.md"
|
||||
|
||||
.PHONY: test-dind
|
||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||
@./scripts/test-dind.sh $(TARGET)
|
||||
|
||||
.PHONY: install
|
||||
install: $(GOFILES) ## install the runner binary via `go install`
|
||||
@@ -202,7 +229,7 @@ docker: ## build the docker image
|
||||
.PHONY: clean
|
||||
clean: ## delete binary and coverage files
|
||||
$(GO) clean -x -i ./...
|
||||
rm -rf coverage.txt $(EXECUTABLE) $(DIST)
|
||||
rm -rf coverage.txt .tmp $(EXECUTABLE) $(DIST)
|
||||
|
||||
.PHONY: version
|
||||
version: ## print the version
|
||||
|
||||
211
README.md
211
README.md
@@ -85,6 +85,48 @@ docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRA
|
||||
|
||||
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
|
||||
|
||||
> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well — otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist.
|
||||
|
||||
### Image flavours
|
||||
|
||||
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
|
||||
|
||||
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
|
||||
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
|
||||
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
|
||||
|
||||
#### `latest` — basic
|
||||
|
||||
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
|
||||
|
||||
```bash
|
||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
|
||||
```
|
||||
|
||||
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
|
||||
|
||||
#### `latest-dind` — Docker-in-Docker
|
||||
|
||||
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
|
||||
|
||||
```bash
|
||||
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||
--name my_runner gitea/runner:latest-dind
|
||||
```
|
||||
|
||||
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged.
|
||||
|
||||
#### `latest-dind-rootless` — rootless Docker-in-Docker
|
||||
|
||||
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
|
||||
|
||||
> **The UID is fixed at 1000.** It comes from the `rootless` user baked into the upstream `docker:dind-rootless` base image, and the bundled daemon always listens on `/run/user/1000/docker.sock` inside the container, so running this flavour as a different user (`--user 1001`) does not work. If you need the runner to talk to a *host* rootless daemon that runs under some other UID, use the `basic` flavour instead and bind-mount that daemon's socket (see [examples/vm/rootless-docker.md](examples/vm/rootless-docker.md)); pointing `DOCKER_HOST` at a host socket from inside `dind-rootless` will not work. Changing the UID otherwise means rebuilding the image from a base with a different `rootless` user.
|
||||
|
||||
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
|
||||
|
||||
### Configuration
|
||||
|
||||
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
||||
@@ -105,26 +147,146 @@ Every option is described in [config.example.yaml](internal/pkg/config/config.ex
|
||||
|
||||
#### Without a config file
|
||||
|
||||
If you omit `-c`, built-in defaults apply (same as an empty YAML document). A small set of **deprecated** environment variables can still override parts of that default config, but **only when no `-c` path was given**; they are ignored if you use a config file:
|
||||
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
|
||||
|
||||
| Variable | Effect |
|
||||
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||
|
||||
### Labels
|
||||
|
||||
Labels decide **which jobs a runner accepts** and **how it runs them**. A job's `runs-on` is matched against the runner's label names; the first match wins and selects the execution environment for that job.
|
||||
|
||||
A label is written as:
|
||||
|
||||
```text
|
||||
<name>[:<schema>[:<args>]]
|
||||
```
|
||||
|
||||
| Part | Meaning |
|
||||
| --- | --- |
|
||||
| `GITEA_DEBUG` | If true, sets log level to `debug` |
|
||||
| `GITEA_TRACE` | If true, sets log level to `trace` |
|
||||
| `GITEA_RUNNER_CAPACITY` | Concurrent jobs (integer) |
|
||||
| `GITEA_RUNNER_FILE` | Registration state file path (default `.runner`) |
|
||||
| `GITEA_RUNNER_ENVIRON` | Extra job env vars as comma-separated `KEY:VALUE` pairs |
|
||||
| `GITEA_RUNNER_ENV_FILE` | Path to an env file merged into job env (same idea as `runner.env_file` in YAML) |
|
||||
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
||||
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
|
||||
| `args` | Only used by the `docker` schema: the image to run the job in. |
|
||||
|
||||
Prefer a YAML file for all settings.
|
||||
Two schemas are supported:
|
||||
|
||||
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
||||
|
||||
```text
|
||||
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
|
||||
```
|
||||
|
||||
- **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there:
|
||||
|
||||
```text
|
||||
macos:host
|
||||
```
|
||||
|
||||
So with the labels
|
||||
|
||||
```text
|
||||
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
|
||||
```
|
||||
|
||||
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
|
||||
|
||||
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
||||
|
||||
If a job's `runs-on` matches none of the runner's labels, the job still runs, in the default `docker.gitea.com/runner-images:ubuntu-latest` image. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||
|
||||
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
||||
|
||||
#### Registration vs config labels
|
||||
|
||||
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
|
||||
|
||||
#### External cache (`actions/cache`)
|
||||
The `daemon` command also accepts `--labels` (which defaults to the `GITEA_RUNNER_LABELS` environment variable), so the labels of an already registered runner can be changed without deleting its registration file. The most explicit source wins:
|
||||
|
||||
If `cache.external_server` is set, you must set `cache.external_secret` to the same value on this runner and on the standalone cache server. Run the server with `gitea-runner cache-server` using a config that defines `cache.external_secret` (and matching `cache.dir` / host / port as needed). Flags `--dir`, `--host`, and `--port` on `cache-server` override the file.
|
||||
```
|
||||
--labels / GITEA_RUNNER_LABELS > runner.labels in the config file > labels in the .runner file
|
||||
```
|
||||
|
||||
Whenever the resulting labels differ from the ones in the registration file, they are written back to it and re-declared to the Gitea instance on startup.
|
||||
|
||||
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
|
||||
|
||||
#### Proxy
|
||||
|
||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||
|
||||
```sh
|
||||
http_proxy=http://proxy.example:3128
|
||||
https_proxy=http://proxy.example:3128
|
||||
no_proxy=gitea.internal,.example.local
|
||||
```
|
||||
|
||||
The runner uses them for its own requests and gives them to every job, in lower and upper case.
|
||||
|
||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||
|
||||
- the cache server
|
||||
- `localhost`, `127.0.0.1` and `::1`
|
||||
- the job's service containers
|
||||
- the Docker daemon, when it is reached over `tcp://`
|
||||
|
||||
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
||||
|
||||
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||
|
||||
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
||||
|
||||
Dockerfile actions are built with these variables as build arguments, so their `RUN` steps can reach the network.
|
||||
|
||||
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
||||
|
||||
#### Caching (`actions/cache`)
|
||||
|
||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||
|
||||
**Cache service v2**
|
||||
|
||||
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
v2: false
|
||||
```
|
||||
|
||||
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle and keeps the untouched copy beside it; a bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork.
|
||||
|
||||
**Shared cache across multiple runners**
|
||||
|
||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||
|
||||
1. Create a config file for the cache server host:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
dir: /data/actcache
|
||||
port: 8088
|
||||
external_secret: "replace-with-a-strong-random-secret"
|
||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||
```
|
||||
|
||||
2. Start the server:
|
||||
|
||||
```bash
|
||||
gitea-runner -c cache-server-config.yaml cache-server
|
||||
```
|
||||
|
||||
3. On every runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
external_server: "http://<cache-server-host>:8088/"
|
||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||
```
|
||||
|
||||
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
||||
|
||||
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
||||
|
||||
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
|
||||
|
||||
#### Official Docker image
|
||||
|
||||
@@ -132,11 +294,32 @@ Besides `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN`, the image en
|
||||
|
||||
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md).
|
||||
|
||||
When `container.bind_workdir` is enabled, stale task workspace directories can be cleaned while the runner is idle:
|
||||
- directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable)
|
||||
While the runner is idle it cleans up after earlier jobs:
|
||||
- when `container.bind_workdir` is enabled, stale task workspace directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
||||
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
||||
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
||||
- on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
|
||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
||||
|
||||
#### Post-task script (`runner.post_task_script`)
|
||||
|
||||
Optional host script that runs **after** each task's built-in cleanup (post-steps, container teardown, bind-workdir removal). Use it for extra machine housekeeping — Docker pruning, disk cleanup, and similar.
|
||||
|
||||
**While the script runs, the runner stops task heartbeats and stays offline from Gitea's perspective until the script exits (or hits `runner.post_task_script_timeout`, default `5m`).** A script that blocks without exiting keeps the runner from taking new work for up to that timeout. Script output goes to the runner log, not the job log; a non-zero exit is warned but does not change the job result.
|
||||
|
||||
On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not supported yet** as the configured path — wrap commands in a `.cmd` file instead.
|
||||
|
||||
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
||||
|
||||
#### Job hooks (`runner.hooks.job_started`, `runner.hooks.job_completed`)
|
||||
|
||||
Optional scripts that run **inside the job environment** (the job container, or the host in host mode), before the job's first step and after its last one. They are the equivalent of GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`, which are read when the settings are unset.
|
||||
|
||||
Because they run where the steps run and see the job's environment, they are the place for per-job setup no workflow should have to carry: registry logins, mirror configuration, or masking runner-wide secrets with `::add-mask::`. Their output is part of the job log and is scanned for workflow commands, and they can export to the job through `$GITHUB_ENV` and `$GITHUB_PATH`.
|
||||
|
||||
Both hooks are synchronous and block the job while they run. Either one exiting non-zero fails the job, and there is no per-hook timeout.
|
||||
|
||||
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||
|
||||
### Example Deployments
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ type Handler struct {
|
||||
storage *Storage
|
||||
router *httprouter.Router
|
||||
listener net.Listener
|
||||
port int
|
||||
server *http.Server
|
||||
logger logrus.FieldLogger
|
||||
|
||||
@@ -157,12 +158,13 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
||||
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
||||
// Artifact GET is signed via query-string HMAC because @actions/cache
|
||||
// does not attach Authorization when downloading archiveLocation.
|
||||
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
|
||||
router.GET(apiPath+"/artifacts/:id", h.signedAuth("", h.get))
|
||||
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
||||
// cache API can authenticate them. Always wired so the routes exist; the
|
||||
// handlers themselves 401 when internalSecret is unset.
|
||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
||||
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
||||
h.registerV2Routes(router)
|
||||
|
||||
h.router = router
|
||||
|
||||
@@ -177,6 +179,12 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr, ok := listener.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
listener.Close()
|
||||
return nil, fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr())
|
||||
}
|
||||
h.port = addr.Port
|
||||
server := &http.Server{
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
Handler: router,
|
||||
@@ -194,9 +202,7 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
||||
|
||||
func (h *Handler) ExternalURL() string {
|
||||
// TODO: make the external url configurable if necessary
|
||||
return fmt.Sprintf("http://%s:%d",
|
||||
h.outboundIP,
|
||||
h.listener.Addr().(*net.TCPAddr).Port)
|
||||
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
||||
}
|
||||
|
||||
// RegisterJob makes token a valid bearer credential for cache requests from
|
||||
@@ -325,10 +331,6 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
|
||||
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
||||
// cache keys are case insensitive
|
||||
for i, key := range keys {
|
||||
keys[i] = strings.ToLower(key)
|
||||
}
|
||||
version := r.URL.Query().Get("version")
|
||||
|
||||
db, err := h.openDB()
|
||||
@@ -338,7 +340,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := findCache(db, cred.Repo, keys, version)
|
||||
cache, err := h.lookupCache(db, cred.Repo, keys, version)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
@@ -347,15 +349,6 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
h.responseJSON(w, r, 204)
|
||||
return
|
||||
}
|
||||
|
||||
if ok, err := h.storage.Exist(cache.ID); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
} else if !ok {
|
||||
_ = db.Delete(cache.ID, cache)
|
||||
h.responseJSON(w, r, 204)
|
||||
return
|
||||
}
|
||||
h.responseJSON(w, r, 200, map[string]any{
|
||||
"result": "hit",
|
||||
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||
@@ -363,6 +356,25 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// entry is dropped on the way out.
|
||||
func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
|
||||
cache, err := findCache(db, repo, keys, version)
|
||||
if err != nil || cache == nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := h.storage.Exist(cache.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
_ = db.Delete(cache.ID, cache)
|
||||
return nil, nil //nolint:nilnil // absence is not an error here
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// POST /_apis/artifactcache/caches
|
||||
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
@@ -371,8 +383,6 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
|
||||
h.responseJSON(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
// cache keys are case insensitive
|
||||
api.Key = strings.ToLower(api.Key)
|
||||
|
||||
cache := api.ToCache()
|
||||
cache.Repo = cred.Repo
|
||||
@@ -437,8 +447,9 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
||||
}
|
||||
if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
h.useCache(id)
|
||||
_ = h.touchCache(uint64(id), false)
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
@@ -479,23 +490,7 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
||||
|
||||
db.Close()
|
||||
|
||||
size, err := h.storage.Commit(cache.ID, cache.Size)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
||||
cache.Size = size
|
||||
|
||||
db, err = h.openDB()
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache.Complete = true
|
||||
if err := db.Update(cache.ID, cache); err != nil {
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.responseJSON(w, r, 500, err)
|
||||
return
|
||||
}
|
||||
@@ -503,8 +498,28 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
||||
h.responseJSON(w, r, 200)
|
||||
}
|
||||
|
||||
// commitCache assembles the uploaded parts and marks the entry complete. The caller must
|
||||
// have closed its store first: Commit concatenates the whole archive and would otherwise
|
||||
// hold bolt's exclusive file lock for the duration.
|
||||
func (h *Handler) commitCache(cache *Cache) error {
|
||||
written, err := h.storage.Commit(cache.ID, cache.Size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
||||
cache.Size = written
|
||||
cache.Complete = true
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return db.Update(cache.ID, cache)
|
||||
}
|
||||
|
||||
// GET /_apis/artifactcache/artifacts/:id
|
||||
// Authenticated via signed URL (see signedURLAuth), not bearer, because the
|
||||
// Authenticated via signed URL (see signedAuth), not bearer, because the
|
||||
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
||||
// Repository scoping is already enforced at find() time; the signature binds
|
||||
// the URL to the specific cache ID and an expiry.
|
||||
@@ -514,7 +529,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.
|
||||
h.responseJSON(w, r, 400, err)
|
||||
return
|
||||
}
|
||||
h.useCache(id)
|
||||
_ = h.touchCache(uint64(id), false)
|
||||
h.storage.Serve(w, r, uint64(id))
|
||||
}
|
||||
|
||||
@@ -548,7 +563,9 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||
@@ -571,7 +588,7 @@ func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
|
||||
return
|
||||
}
|
||||
expected := h.computeSignature(id, exp)
|
||||
expected := h.computeSignature(purpose, id, exp)
|
||||
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
||||
return
|
||||
@@ -655,19 +672,26 @@ func credFromContext(ctx context.Context) JobCredential {
|
||||
return JobCredential{}
|
||||
}
|
||||
|
||||
func (h *Handler) computeSignature(cacheID, exp int64) string {
|
||||
// computeSignature signs a URL for one cache entry and expiry. purpose is mixed into the
|
||||
// message so a URL handed out for writing an entry cannot be replayed to read one, and the
|
||||
// other way round. Downloads use the empty purpose, the message v1 has always signed.
|
||||
func (h *Handler) computeSignature(purpose string, cacheID, exp int64) string {
|
||||
mac := hmac.New(sha256.New, h.secret)
|
||||
fmt.Fprintf(mac, "%d:%d", cacheID, exp)
|
||||
fmt.Fprintf(mac, "%s%d:%d", purpose, cacheID, exp)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
|
||||
func (h *Handler) signedURL(path, purpose string, cacheID uint64, exp time.Time) string {
|
||||
expUnix := exp.Unix()
|
||||
sig := h.computeSignature(int64(cacheID), expUnix)
|
||||
q := url.Values{}
|
||||
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
||||
q.Set("sig", sig)
|
||||
return fmt.Sprintf("%s%s/artifacts/%d?%s", h.ExternalURL(), apiPath, cacheID, q.Encode())
|
||||
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
|
||||
return fmt.Sprintf("%s%s/%d?%s", h.ExternalURL(), path, cacheID, q.Encode())
|
||||
}
|
||||
|
||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||
return h.signedURL(apiPath+"/artifacts", "", cacheID, exp)
|
||||
}
|
||||
|
||||
// if not found, return (nil, nil) instead of an error.
|
||||
@@ -675,16 +699,12 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
||||
cache := &Cache{}
|
||||
for _, prefix := range keys {
|
||||
// if a key in the list matches exactly, don't return partial matches
|
||||
if err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(prefix).
|
||||
And("Version").Eq(version).
|
||||
And("Complete").Eq(true).
|
||||
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find cache: %w", err)
|
||||
}
|
||||
return cache, nil
|
||||
exact, err := findExactCache(db, repo, prefix, version, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exact != nil {
|
||||
return exact, nil
|
||||
}
|
||||
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
||||
re, err := regexp.Compile(prefixPattern)
|
||||
@@ -707,6 +727,34 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
// 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
|
||||
// its callers need: a new key that is only a prefix of an existing key is not the same entry.
|
||||
//
|
||||
// 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
|
||||
// touches UsedAt on every part.
|
||||
func findExactCache(db *bolthold.Store, repo, key, version string, complete bool) (*Cache, error) {
|
||||
sortBy := "UsedAt"
|
||||
if complete {
|
||||
sortBy = "CreatedAt"
|
||||
}
|
||||
cache := &Cache{}
|
||||
err := db.FindOne(cache,
|
||||
bolthold.Where("Repo").Eq(repo).
|
||||
And("Key").Eq(key).
|
||||
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 {
|
||||
return nil, fmt.Errorf("find cache: %w", err)
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
||||
return fmt.Errorf("insert cache: %w", err)
|
||||
@@ -718,18 +766,30 @@ func insertCache(db *bolthold.Store, cache *Cache) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) useCache(id int64) {
|
||||
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||
// it also refuses an entry that is already complete, which is what the v2 blob route needs: its
|
||||
// upload URL outlives the finalize call, and overwriting a finished entry would leave the blob
|
||||
// other jobs restore no longer matching its recorded size. An entry missing from the store is
|
||||
// accepted, since the signature proves the id was handed out.
|
||||
func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache := &Cache{}
|
||||
if err := db.Get(id, cache); err != nil {
|
||||
return
|
||||
if errors.Is(err, bolthold.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if requireIncomplete && cache.Complete {
|
||||
return fmt.Errorf("cache %d: already complete", id)
|
||||
}
|
||||
cache.UsedAt = time.Now().Unix()
|
||||
_ = db.Update(cache.ID, cache)
|
||||
return db.Update(cache.ID, cache)
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -338,6 +339,54 @@ func TestHandler(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload write failure returns only error", func(t *testing.T) {
|
||||
key := strings.ToLower(t.Name())
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
var id uint64
|
||||
{
|
||||
body, err := json.Marshal(&Request{
|
||||
Key: key,
|
||||
Version: version,
|
||||
Size: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
got := struct {
|
||||
CacheID uint64 `json:"cacheId"`
|
||||
}{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
id = got.CacheID
|
||||
}
|
||||
|
||||
storageFile := filepath.Join(dir, "not-a-directory")
|
||||
require.NoError(t, os.WriteFile(storageFile, []byte("blocked"), 0o600))
|
||||
originalStorage := handler.storage
|
||||
handler.storage = &Storage{rootDir: storageFile}
|
||||
defer func() {
|
||||
handler.storage = originalStorage
|
||||
}()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPatch,
|
||||
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(make([]byte, 100)))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||
resp, err := testClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, 500, resp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var got map[string]string
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
assert.NotEmpty(t, got["error"])
|
||||
})
|
||||
|
||||
t.Run("commit early", func(t *testing.T) {
|
||||
key := strings.ToLower(t.Name())
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
@@ -396,13 +445,6 @@ func TestHandler(t *testing.T) {
|
||||
require.Equal(t, 404, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("get with not exist id", func(t *testing.T) {
|
||||
resp, err := testClient.Get(signArtifactURL(handler, 100))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, 404, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("get with multiple keys", func(t *testing.T) {
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
key := strings.ToLower(t.Name())
|
||||
@@ -420,7 +462,8 @@ func TestHandler(t *testing.T) {
|
||||
_, err := rand.Read(contents[i])
|
||||
require.NoError(t, err)
|
||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
||||
// ensure CreatedAt of caches are different, in upload order
|
||||
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||
}
|
||||
|
||||
reqKeys := strings.Join([]string{
|
||||
@@ -459,17 +502,20 @@ func TestHandler(t *testing.T) {
|
||||
assert.Equal(t, contents[except], content)
|
||||
})
|
||||
|
||||
t.Run("case insensitive", func(t *testing.T) {
|
||||
t.Run("case preserved", func(t *testing.T) {
|
||||
// Some actions (e.g. actions/setup-go, actions/setup-node) build cache keys that contain mixed-case fragments such as RUNNER_OS=Linux,
|
||||
// then compare the cacheKey returned by the cache server to their original key with case-sensitive equality to decide whether the
|
||||
// cache was a complete hit. The server must therefore preserve the original key case.
|
||||
|
||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||
key := strings.ToLower(t.Name())
|
||||
key := strings.ToLower(t.Name()) + "_ABC"
|
||||
content := make([]byte, 100)
|
||||
_, err := rand.Read(content)
|
||||
require.NoError(t, err)
|
||||
uploadCacheNormally(t, base, key+"_ABC", version, content)
|
||||
uploadCacheNormally(t, base, key, version, content)
|
||||
|
||||
{
|
||||
reqKey := key + "_aBc"
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
@@ -480,7 +526,8 @@ func TestHandler(t *testing.T) {
|
||||
}{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
assert.Equal(t, "hit", got.Result)
|
||||
assert.Equal(t, key+"_abc", got.CacheKey)
|
||||
assert.Equal(t, key, got.CacheKey)
|
||||
assert.NotEqual(t, strings.ToLower(key), got.CacheKey)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -501,7 +548,8 @@ func TestHandler(t *testing.T) {
|
||||
_, err := rand.Read(contents[i])
|
||||
require.NoError(t, err)
|
||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
||||
// ensure CreatedAt of caches are different, in upload order
|
||||
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||
}
|
||||
|
||||
reqKeys := strings.Join([]string{
|
||||
@@ -554,7 +602,8 @@ func TestHandler(t *testing.T) {
|
||||
_, err := rand.Read(contents[i])
|
||||
require.NoError(t, err)
|
||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
||||
// ensure CreatedAt of caches are different, in upload order
|
||||
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||
}
|
||||
|
||||
reqKeys := strings.Join([]string{
|
||||
@@ -593,6 +642,20 @@ func TestHandler(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// backdateCache rewrites a cache's CreatedAt. It has one-second resolution, so age-ordering
|
||||
// tests set it directly instead of sleeping a second between uploads.
|
||||
func backdateCache(t *testing.T, handler *Handler, key string, age time.Duration) {
|
||||
db, err := handler.openDB()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
var caches []*Cache
|
||||
require.NoError(t, db.Find(&caches, bolthold.Where("Key").Eq(key)))
|
||||
require.Len(t, caches, 1)
|
||||
caches[0].CreatedAt = time.Now().Add(-age).Unix()
|
||||
require.NoError(t, db.Update(caches[0].ID, caches[0]))
|
||||
}
|
||||
|
||||
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
var id uint64
|
||||
{
|
||||
@@ -643,7 +706,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
||||
}{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
assert.Equal(t, "hit", got.Result)
|
||||
assert.Equal(t, strings.ToLower(key), got.CacheKey)
|
||||
assert.Equal(t, key, got.CacheKey)
|
||||
archiveLocation = got.ArchiveLocation
|
||||
}
|
||||
{
|
||||
@@ -978,14 +1041,14 @@ func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
||||
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
exp := time.Now().Add(artifactURLTTL).Unix()
|
||||
sig := first.computeSignature(42, exp)
|
||||
sig := first.computeSignature("", 42, exp)
|
||||
require.NoError(t, first.Close())
|
||||
|
||||
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
defer second.Close()
|
||||
|
||||
assert.Equal(t, sig, second.computeSignature(42, exp))
|
||||
assert.Equal(t, sig, second.computeSignature("", 42, exp))
|
||||
}
|
||||
|
||||
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that
|
||||
|
||||
268
act/artifactcache/handler_v2.go
Normal file
268
act/artifactcache/handler_v2.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
// The cache service v2 API. A client on this version talks twirp to
|
||||
// `github.actions.results.api.v1.CacheService` instead of the /_apis/artifactcache
|
||||
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
|
||||
// Both API versions are served from the same store, so a repository keeps its cache
|
||||
// when a workflow moves between action versions.
|
||||
const (
|
||||
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
|
||||
|
||||
// blobPath authenticates by signature, because the client uploads without an
|
||||
// Authorization header. Downloads are handed the v1 artifact URL instead.
|
||||
blobPath = apiPath + "/blobs"
|
||||
|
||||
// blobUploadPurpose keeps an upload URL from being replayed to read an entry.
|
||||
blobUploadPurpose = "upload:"
|
||||
|
||||
blobUploadURLTTL = time.Hour
|
||||
|
||||
// twirpInternal is the only error code that is not the client's fault.
|
||||
twirpInternal = "internal"
|
||||
)
|
||||
|
||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||
router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
||||
router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
||||
router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
||||
router.PUT(blobPath+"/:id", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
||||
}
|
||||
|
||||
// An entry that already exists is reported as not ok, which is how the client learns to skip
|
||||
// the upload.
|
||||
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
if req.Key == "" || req.Version == "" {
|
||||
h.twirpError(w, r, "invalid_argument", errors.New("key and version are required"))
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// An exact (key, version) match means the entry is already cached; the client then skips
|
||||
// the upload. A prefix match must not count here, or a shorter key would be reported as
|
||||
// existing and silently never saved.
|
||||
if existing, err := findExactCache(db, cred.Repo, req.Key, req.Version, true); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
} else if existing != nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
cache := &Cache{
|
||||
Repo: cred.Repo,
|
||||
Key: req.Key,
|
||||
Version: req.Version,
|
||||
Size: -1, // the size is only known at finalize time
|
||||
CreatedAt: now,
|
||||
UsedAt: now,
|
||||
}
|
||||
if err := insertCache(db, cache); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"signedUploadUrl": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
db.Close() // commitCache needs the store closed
|
||||
|
||||
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
// int64 fields travel as strings in the proto JSON mapping.
|
||||
"entryId": strconv.FormatUint(cache.ID, 10),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache, err := h.lookupCache(db, cred.Repo, req.keys(), req.Version)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
if cache == nil {
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"signedDownloadUrl": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||
"matchedKey": cache.Key,
|
||||
})
|
||||
}
|
||||
|
||||
// The archive arrives over the subset of the Azure blob API the toolkit uses: a small
|
||||
// cache is a single PUT, a large one is staged as blocks that a final block list puts
|
||||
// in order.
|
||||
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.touchCache(id, true); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
switch strings.ToLower(query.Get("comp")) {
|
||||
case "block":
|
||||
blockID := query.Get("blockid")
|
||||
if blockID == "" {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("missing blockid"))
|
||||
return
|
||||
}
|
||||
err = h.storage.WriteBlock(id, blockID, r.Body)
|
||||
case "blocklist":
|
||||
var list struct{ Latest []string }
|
||||
if err := xml.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&list); err != nil {
|
||||
h.responseJSON(w, r, http.StatusBadRequest, fmt.Errorf("malformed block list: %w", err))
|
||||
return
|
||||
}
|
||||
err = h.storage.OrderBlocks(id, list.Latest)
|
||||
default:
|
||||
err = h.storage.Write(id, 0, r.Body)
|
||||
}
|
||||
if err != nil {
|
||||
h.responseJSON(w, r, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// twirpNotOK is the negative answer all three endpoints share: no such entry to restore, no
|
||||
// reservation to finalize, or an entry that already exists and need not be uploaded again.
|
||||
func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false})
|
||||
}
|
||||
|
||||
// twirpError reports in the shape a twirp client expects, so the toolkit surfaces the message
|
||||
// instead of a parse error.
|
||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||
status := http.StatusBadRequest
|
||||
if code == twirpInternal {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||
}
|
||||
|
||||
// The twirp request bodies. The toolkit's client serialises with useProtoFieldName, so the proto
|
||||
// names are what arrive; the camelCase spellings of the same mapping are accepted too, as are
|
||||
// int64s sent as a bare number rather than the string the mapping prescribes.
|
||||
type (
|
||||
v2CreateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
v2FinalizeRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
SizeBytes json.Number `json:"size_bytes"`
|
||||
SizeBytesCamel json.Number `json:"sizeBytes"`
|
||||
}
|
||||
|
||||
v2DownloadRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
RestoreKeys []string `json:"restore_keys"`
|
||||
RestoreKeysCamel []string `json:"restoreKeys"`
|
||||
}
|
||||
)
|
||||
|
||||
func (d v2DownloadRequest) keys() []string {
|
||||
restoreKeys := d.RestoreKeys
|
||||
if len(restoreKeys) == 0 {
|
||||
restoreKeys = d.RestoreKeysCamel
|
||||
}
|
||||
return append([]string{d.Key}, restoreKeys...)
|
||||
}
|
||||
|
||||
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
||||
var req T
|
||||
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
|
||||
return req, err
|
||||
}
|
||||
236
act/artifactcache/handler_v2_test.go
Normal file
236
act/artifactcache/handler_v2_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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 {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(request)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.Post(handler.ExternalURL()+cacheServiceV2Path+"/"+method, "application/json", bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
got := map[string]any{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&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 {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, url, bytes.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func getURL(t *testing.T, url string) []byte {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
func startTestHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
|
||||
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
handler.RegisterJob(testToken, testRepo)
|
||||
return handler
|
||||
}
|
||||
|
||||
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
||||
// with the upload URL it used.
|
||||
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
||||
t.Helper()
|
||||
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
|
||||
require.Equal(t, true, created["ok"])
|
||||
uploadURL, _ = created["signedUploadUrl"].(string)
|
||||
require.NotEmpty(t, uploadURL)
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
|
||||
|
||||
return v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": key, "version": version,
|
||||
"size_bytes": strconv.Itoa(len(content)),
|
||||
}), 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) {
|
||||
handler := startTestHandler(t)
|
||||
content := []byte("the cached archive")
|
||||
|
||||
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
|
||||
|
||||
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
||||
require.Equal(t, true, finalized["ok"])
|
||||
assert.NotEmpty(t, finalized["entryId"])
|
||||
|
||||
// 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")))
|
||||
resp, err := http.Get(uploadURL) //nolint:noctx // the URL is the server under test
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "deps-v1", got["matchedKey"])
|
||||
downloadURL, _ := got["signedDownloadUrl"].(string)
|
||||
require.NotEmpty(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) {
|
||||
handler := startTestHandler(t)
|
||||
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||
uploadURL, _ := created["signedUploadUrl"].(string)
|
||||
require.NotEmpty(t, uploadURL)
|
||||
|
||||
blocks := map[string][]byte{}
|
||||
var order []string
|
||||
for i, part := range []string{"hello ", "world", "!"} {
|
||||
blockID := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "block-%d", i))
|
||||
blocks[blockID] = []byte(part)
|
||||
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]} {
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID]))
|
||||
}
|
||||
|
||||
var list bytes.Buffer
|
||||
list.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||
for _, blockID := range order {
|
||||
fmt.Fprintf(&list, "<Latest>%s</Latest>", blockID)
|
||||
}
|
||||
list.WriteString(`</BlockList>`)
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||
|
||||
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||
})
|
||||
require.Equal(t, true, finalized["ok"])
|
||||
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "hello world!", string(getURL(t, got["signedDownloadUrl"].(string))))
|
||||
}
|
||||
|
||||
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
handler := startTestHandler(t)
|
||||
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||
require.Equal(t, true, saved["ok"])
|
||||
|
||||
t.Run("reports a miss for an unknown key", func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "nothing", "version": "v1"})
|
||||
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"} {
|
||||
t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{
|
||||
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
|
||||
})
|
||||
require.Equal(t, true, got["ok"])
|
||||
assert.Equal(t, "deps-abc", got["matchedKey"])
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("an existing entry is not reserved twice", func(t *testing.T) {
|
||||
again := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||
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) {
|
||||
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
|
||||
require.Equal(t, true, reserved["ok"])
|
||||
assert.NotEmpty(t, reserved["signedUploadUrl"])
|
||||
})
|
||||
|
||||
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
||||
})
|
||||
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) {
|
||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
|
||||
require.Equal(t, http.StatusCreated, putBlob(t, created["signedUploadUrl"].(string), []byte("four")))
|
||||
|
||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "wrong-size", "version": "v1", "size_bytes": 99,
|
||||
})
|
||||
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) {
|
||||
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
got := map[string]any{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
assert.Equal(t, "deps-abc", got["cacheKey"])
|
||||
assert.NotEmpty(t, got["archiveLocation"])
|
||||
})
|
||||
|
||||
// The cache of one repository must stay invisible to another, as it does for the v1 API.
|
||||
t.Run("another repository sees nothing", func(t *testing.T) {
|
||||
handler.RegisterJob("other-runtime-token", "other/repo")
|
||||
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
||||
|
||||
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||
assert.Equal(t, false, got["ok"])
|
||||
})
|
||||
}
|
||||
@@ -5,12 +5,15 @@
|
||||
package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
@@ -37,7 +40,10 @@ func (s *Storage) Exist(id uint64) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||
name := s.tempName(id, offset)
|
||||
return s.writeFile(s.tempName(id, offset), reader)
|
||||
}
|
||||
|
||||
func (s *Storage) writeFile(name string, reader io.Reader) error {
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -51,6 +57,26 @@ func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error {
|
||||
return s.writeFile(s.blockName(id, blockID), reader)
|
||||
}
|
||||
|
||||
// OrderBlocks renames the staged blocks into the order the block list gives. A block the list
|
||||
// does not name keeps its staged name, which is how Commit leaves it out, as Azure drops it. One
|
||||
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||
// never does, so no rename can collide with a block not yet moved.
|
||||
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||
for i, blockID := range blockIDs {
|
||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("block %q of cache %d was never uploaded: %w", blockID, id, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
defer func() {
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
@@ -65,6 +91,31 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
written, err := assemble(name, tempNames)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// If size is less than 0, it means the size is unknown.
|
||||
// We can't check the size of the file, just skip the check.
|
||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
if size >= 0 && written != size {
|
||||
_ = os.Remove(name)
|
||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// assemble concatenates the uploaded parts into name. A single part, which is what the v2 API
|
||||
// produces below the client's block threshold, is already the whole archive and is moved.
|
||||
func assemble(name string, tempNames []string) (int64, error) {
|
||||
if len(tempNames) == 1 {
|
||||
info, err := os.Stat(tempNames[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), os.Rename(tempNames[0], name)
|
||||
}
|
||||
|
||||
file, err := os.Create(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -84,16 +135,6 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||
}
|
||||
written += n
|
||||
}
|
||||
|
||||
// If size is less than 0, it means the size is unknown.
|
||||
// We can't check the size of the file, just skip the check.
|
||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
||||
if size >= 0 && written != size {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(name)
|
||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
@@ -119,6 +160,17 @@ func (s *Storage) tempName(id uint64, offset int64) string {
|
||||
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
|
||||
}
|
||||
|
||||
// blockFilePrefix marks a staged, not yet ordered block, so that tempNames can keep it out of
|
||||
// Commit's name-ordered concatenation.
|
||||
const blockFilePrefix = "block-"
|
||||
|
||||
func (s *Storage) blockName(id uint64, blockID string) string {
|
||||
// The block id is client-chosen (base64), so it is hashed rather than trusted as a
|
||||
// path element.
|
||||
sum := sha256.Sum256([]byte(blockID))
|
||||
return filepath.Join(s.tempDir(id), blockFilePrefix+hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||
dir := s.tempDir(id)
|
||||
files, err := os.ReadDir(dir)
|
||||
@@ -127,7 +179,7 @@ func (s *Storage) tempNames(id uint64) ([]string, error) {
|
||||
}
|
||||
var names []string
|
||||
for _, v := range files {
|
||||
if !v.IsDir() {
|
||||
if !v.IsDir() && !strings.HasPrefix(v.Name(), blockFilePrefix) {
|
||||
names = append(names, filepath.Join(dir, v.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,25 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type writableMapFile struct {
|
||||
@@ -234,89 +235,133 @@ func TestDownloadArtifactFile(t *testing.T) {
|
||||
assert.Equal("content", string(data))
|
||||
}
|
||||
|
||||
type TestJobFileInfo struct {
|
||||
workdir string
|
||||
workflowPath string
|
||||
eventName string
|
||||
errorMessage string
|
||||
platforms map[string]string
|
||||
containerArchitecture string
|
||||
}
|
||||
|
||||
var (
|
||||
artifactsPath = path.Join(os.TempDir(), "test-artifacts")
|
||||
artifactsAddr = "127.0.0.1"
|
||||
artifactsPort = "12345"
|
||||
)
|
||||
|
||||
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
|
||||
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
|
||||
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
|
||||
// and reachable everywhere, including when the CI job is itself a container.
|
||||
func TestArtifactFlow(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
artifactPath := t.TempDir()
|
||||
|
||||
// Serve the exact routes Serve() wires up, on a real loopback socket via httptest. httptest
|
||||
// picks a free port and Close() tears the server down synchronously — avoiding both the
|
||||
// port-rebind race and Serve()'s detached ListenAndServe goroutine, which logger.Fatal()s
|
||||
// (process exit) on a bind error and can outlive the test's temp-dir cleanup.
|
||||
router := httprouter.New()
|
||||
fsys := readWriteFSImpl{}
|
||||
uploads(router, artifactPath, fsys)
|
||||
downloads(router, artifactPath, fsys)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
baseURL := server.URL
|
||||
client := server.Client()
|
||||
client.Timeout = 5 * time.Second
|
||||
|
||||
// request performs one HTTP call and returns the status and body. The default transport adds
|
||||
// Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain.
|
||||
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, rawURL, body)
|
||||
require.NoError(t, err)
|
||||
maps.Copy(req.Header, header)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp.StatusCode, data
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Run("upload-and-download", func(t *testing.T) {
|
||||
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n"
|
||||
|
||||
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort)
|
||||
defer cancel()
|
||||
status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var prep FileContainerResourceURL
|
||||
require.NoError(t, json.Unmarshal(data, &prep))
|
||||
require.Equal(t, baseURL+"/upload/"+runID, prep.FileContainerResourceURL)
|
||||
|
||||
platforms := map[string]string{
|
||||
"ubuntu-latest": "node:24-bookworm", // Don't use node:24-bookworm-slim because it doesn't have curl command, which is used in the tests
|
||||
}
|
||||
status, data = request(t, http.MethodPut, prep.FileContainerResourceURL+"?itemPath="+url.QueryEscape(item), strings.NewReader(content), nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var msg ResponseMessage
|
||||
require.NoError(t, json.Unmarshal(data, &msg))
|
||||
require.Equal(t, "success", msg.Message)
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
{"testdata", "upload-and-download", "push", "", platforms, ""},
|
||||
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
|
||||
}
|
||||
log.SetLevel(log.DebugLevel)
|
||||
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
|
||||
for _, table := range tables {
|
||||
runTestJobFile(ctx, t, table)
|
||||
}
|
||||
}
|
||||
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var list NamedFileContainerResourceURLResponse
|
||||
require.NoError(t, json.Unmarshal(data, &list))
|
||||
require.Equal(t, 1, list.Count)
|
||||
require.Equal(t, "my-artifact", list.Value[0].Name)
|
||||
|
||||
func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
|
||||
t.Run(tjfi.workflowPath, func(t *testing.T) {
|
||||
fmt.Printf("::group::%s\n", tjfi.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
status, data = request(t, http.MethodGet, list.Value[0].FileContainerResourceURL+"?itemPath=my-artifact", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var items ContainerItemResponse
|
||||
require.NoError(t, json.Unmarshal(data, &items))
|
||||
require.Len(t, items.Value, 1)
|
||||
require.Equal(t, "file", items.Value[0].ItemType)
|
||||
require.Equal(t, "my-artifact/data.txt", items.Value[0].Path)
|
||||
|
||||
if err := os.RemoveAll(artifactsPath); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, content, string(data))
|
||||
|
||||
workdir, err := filepath.Abs(tjfi.workdir)
|
||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
|
||||
runnerConfig := &runner.Config{
|
||||
Workdir: workdir,
|
||||
BindWorkdir: false,
|
||||
EventName: tjfi.eventName,
|
||||
Platforms: tjfi.platforms,
|
||||
ReuseContainers: false,
|
||||
ContainerArchitecture: tjfi.containerArchitecture,
|
||||
GitHubInstance: "github.com",
|
||||
ArtifactServerPath: artifactsPath,
|
||||
ArtifactServerAddr: artifactsAddr,
|
||||
ArtifactServerPort: artifactsPort,
|
||||
}
|
||||
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, content, string(stored))
|
||||
})
|
||||
|
||||
runner, err := runner.New(runnerConfig)
|
||||
assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
t.Run("gzip-roundtrip", func(t *testing.T) {
|
||||
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
||||
|
||||
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
_, err := gz.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gz.Close())
|
||||
|
||||
plan, err := planner.PlanEvent(tjfi.eventName)
|
||||
if err == nil {
|
||||
err = runner.NewPlanExecutor(plan)(ctx)
|
||||
if tjfi.errorMessage == "" {
|
||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
} else {
|
||||
assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
} else {
|
||||
assert.Nil(t, plan)
|
||||
}
|
||||
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape(item),
|
||||
&buf, http.Header{"Content-Encoding": []string{"gzip"}})
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
|
||||
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
// stored compressed, with the server's gzip marker suffix
|
||||
_, err = os.Stat(filepath.Join(artifactPath, runID, "logs", "app.log.gz__"))
|
||||
require.NoError(t, err)
|
||||
|
||||
status, data = request(t, http.MethodGet, baseURL+"/download/"+runID+"?itemPath=logs", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var items ContainerItemResponse
|
||||
require.NoError(t, json.Unmarshal(data, &items))
|
||||
require.Len(t, items.Value, 1)
|
||||
require.Equal(t, "logs/app.log", items.Value[0].Path)
|
||||
|
||||
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, content, string(data))
|
||||
})
|
||||
|
||||
// GHSL-2023-004: an itemPath that climbs out of the run directory must be neutralised so the
|
||||
// blob cannot be written outside the artifact root.
|
||||
t.Run("GHSL-2023-004", func(t *testing.T) {
|
||||
const runID, content = "3", "contained\n"
|
||||
|
||||
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape("../../escape.txt"),
|
||||
strings.NewReader(content), nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
|
||||
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "escape.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, content, string(stored))
|
||||
|
||||
_, err = os.Stat(filepath.Join(filepath.Dir(artifactPath), "escape.txt"))
|
||||
require.True(t, os.IsNotExist(err), "upload escaped the artifact root")
|
||||
|
||||
status, data = request(t, http.MethodGet, baseURL+"/artifact/"+runID+"/escape.txt", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, content, string(data))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -345,6 +390,43 @@ func TestMkdirFsImplSafeResolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
|
||||
fsys := readWriteFSImpl{}
|
||||
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
|
||||
|
||||
w, err := fsys.OpenWritable(name)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("first"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
w, err = fsys.OpenAppendable(name)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("-second"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
got, err := os.ReadFile(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "first-second", string(got))
|
||||
|
||||
w, err = fsys.OpenWritable(name)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("replaced"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
got, err = os.ReadFile(name)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "replaced", string(got))
|
||||
}
|
||||
|
||||
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
|
||||
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
|
||||
require.NotNil(t, cancel)
|
||||
cancel()
|
||||
}
|
||||
|
||||
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
|
||||
name: "GHSL-2023-0004"
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "hello world" > test.txt
|
||||
- name: curl upload
|
||||
run: curl --silent --show-error --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: my-artifact
|
||||
path: test-artifacts
|
||||
- name: 'Verify Artifact #1'
|
||||
run: |
|
||||
file="test-artifacts/secret.txt"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file)" != "hello world" ] ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
- name: Verify download should work by clean extra dots
|
||||
run: curl --silent --show-error --fail --path-as-is -o out.txt ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt
|
||||
- name: 'Verify download content'
|
||||
run: |
|
||||
file="out.txt"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file)" != "hello world" ] ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,230 +0,0 @@
|
||||
|
||||
name: "Test that artifact uploads and downloads succeed"
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: mkdir -p path/to/artifact
|
||||
- run: echo hello > path/to/artifact/world.txt
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: my-artifact
|
||||
path: path/to/artifact/world.txt
|
||||
|
||||
- run: rm -rf path
|
||||
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: my-artifact
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -la
|
||||
|
||||
# Test end-to-end by uploading two artifacts and then downloading them
|
||||
- name: Create artifact files
|
||||
run: |
|
||||
mkdir -p path/to/dir-1
|
||||
mkdir -p path/to/dir-2
|
||||
mkdir -p path/to/dir-3
|
||||
mkdir -p path/to/dir-5
|
||||
mkdir -p path/to/dir-6
|
||||
mkdir -p path/to/dir-7
|
||||
echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt
|
||||
echo "Hello world from file #2" > path/to/dir-2/file2.txt
|
||||
echo "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" > path/to/dir-3/gzip.txt
|
||||
dd if=/dev/random of=path/to/dir-5/file5.rnd bs=1024 count=1024
|
||||
dd if=/dev/random of=path/to/dir-6/file6.rnd bs=1024 count=$((10*1024))
|
||||
dd if=/dev/random of=path/to/dir-7/file7.rnd bs=1024 count=$((10*1024))
|
||||
|
||||
# Upload a single file artifact
|
||||
- name: 'Upload artifact #1'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Artifact-A'
|
||||
path: path/to/dir-1/file1.txt
|
||||
|
||||
# Upload using a wildcard pattern, name should default to 'artifact' if not provided
|
||||
- name: 'Upload artifact #2'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
path: path/**/dir*/
|
||||
|
||||
# Upload a directory that contains a file that will be uploaded with GZip
|
||||
- name: 'Upload artifact #3'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'GZip-Artifact'
|
||||
path: path/to/dir-3/
|
||||
|
||||
# Upload a directory that contains a file that will be uploaded with GZip
|
||||
- name: 'Upload artifact #4'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Multi-Path-Artifact'
|
||||
path: |
|
||||
path/to/dir-1/*
|
||||
path/to/dir-[23]/*
|
||||
!path/to/dir-3/*.txt
|
||||
|
||||
# Upload a mid-size file artifact
|
||||
- name: 'Upload artifact #5'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Mid-Size-Artifact'
|
||||
path: path/to/dir-5/file5.rnd
|
||||
|
||||
# Upload a big file artifact
|
||||
- name: 'Upload artifact #6'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Big-Artifact'
|
||||
path: path/to/dir-6/file6.rnd
|
||||
|
||||
# Upload a big file artifact twice
|
||||
- name: 'Upload artifact #7 (First)'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Big-Uploaded-Twice'
|
||||
path: path/to/dir-7/file7.rnd
|
||||
|
||||
# Upload a big file artifact twice
|
||||
- name: 'Upload artifact #7 (Second)'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Big-Uploaded-Twice'
|
||||
path: path/to/dir-7/file7.rnd
|
||||
|
||||
# Verify artifacts. Switch to download-artifact@v2 once it's out of preview
|
||||
|
||||
# Download Artifact #1 and verify the correctness of the content
|
||||
- name: 'Download artifact #1'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Artifact-A'
|
||||
path: some/new/path
|
||||
|
||||
- name: 'Verify Artifact #1'
|
||||
run: |
|
||||
file="some/new/path/file1.txt"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file)" != "Lorem ipsum dolor sit amet" ] ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download Artifact #2 and verify the correctness of the content
|
||||
- name: 'Download artifact #2'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'artifact'
|
||||
path: some/other/path
|
||||
|
||||
- name: 'Verify Artifact #2'
|
||||
run: |
|
||||
file1="some/other/path/to/dir-1/file1.txt"
|
||||
file2="some/other/path/to/dir-2/file2.txt"
|
||||
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||
echo "Expected files do not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||
echo "File contents of downloaded artifacts are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download Artifact #3 and verify the correctness of the content
|
||||
- name: 'Download artifact #3'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'GZip-Artifact'
|
||||
path: gzip/artifact/path
|
||||
|
||||
# Because a directory was used as input during the upload the parent directories, path/to/dir-3/, should not be included in the uploaded artifact
|
||||
- name: 'Verify Artifact #3'
|
||||
run: |
|
||||
gzipFile="gzip/artifact/path/gzip.txt"
|
||||
if [ ! -f $gzipFile ] ; then
|
||||
echo "Expected file do not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $gzipFile)" != "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" ] ; then
|
||||
echo "File contents of downloaded artifact is incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Download artifact #4'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Multi-Path-Artifact'
|
||||
path: multi/artifact
|
||||
|
||||
- name: 'Verify Artifact #4'
|
||||
run: |
|
||||
file1="multi/artifact/dir-1/file1.txt"
|
||||
file2="multi/artifact/dir-2/file2.txt"
|
||||
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||
echo "Expected files do not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||
echo "File contents of downloaded artifacts are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Download artifact #5'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Mid-Size-Artifact'
|
||||
path: mid-size/artifact/path
|
||||
|
||||
- name: 'Verify Artifact #5'
|
||||
run: |
|
||||
file="mid-size/artifact/path/file5.rnd"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if ! diff $file path/to/dir-5/file5.rnd ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Download artifact #6'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Big-Artifact'
|
||||
path: big/artifact/path
|
||||
|
||||
- name: 'Verify Artifact #6'
|
||||
run: |
|
||||
file="big/artifact/path/file6.rnd"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if ! diff $file path/to/dir-6/file6.rnd ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Download artifact #7'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Big-Uploaded-Twice'
|
||||
path: big-uploaded-twice/artifact/path
|
||||
|
||||
- name: 'Verify Artifact #7'
|
||||
run: |
|
||||
file="big-uploaded-twice/artifact/path/file7.rnd"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if ! diff $file path/to/dir-7/file7.rnd ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
// CartesianProduct takes map of lists and returns list of unique tuples
|
||||
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
||||
listNames := make([]string, 0)
|
||||
lists := make([][]any, 0)
|
||||
for k, v := range mapOfLists {
|
||||
listNames = append(listNames, k)
|
||||
lists = append(lists, v)
|
||||
}
|
||||
|
||||
listCart := cartN(lists...)
|
||||
|
||||
rtn := make([]map[string]any, 0)
|
||||
for _, list := range listCart {
|
||||
vMap := make(map[string]any)
|
||||
for i, v := range list {
|
||||
vMap[listNames[i]] = v
|
||||
}
|
||||
rtn = append(rtn, vMap)
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
|
||||
func cartN(a ...[]any) [][]any {
|
||||
c := 1
|
||||
for _, a := range a {
|
||||
c *= len(a)
|
||||
}
|
||||
if c == 0 || len(a) == 0 {
|
||||
return nil
|
||||
}
|
||||
p := make([][]any, c)
|
||||
b := make([]any, c*len(a))
|
||||
n := make([]int, len(a))
|
||||
s := 0
|
||||
for i := range p {
|
||||
e := s + len(a)
|
||||
pi := b[s:e]
|
||||
p[i] = pi
|
||||
s = e
|
||||
for j, n := range n {
|
||||
pi[j] = a[j][n]
|
||||
}
|
||||
for j := len(n) - 1; j >= 0; j-- {
|
||||
n[j]++
|
||||
if n[j] < len(a[j]) {
|
||||
break
|
||||
}
|
||||
n[j] = 0
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCartesianProduct(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
input := map[string][]any{
|
||||
"foo": {1, 2, 3, 4},
|
||||
"bar": {"a", "b", "c"},
|
||||
"baz": {false, true},
|
||||
}
|
||||
|
||||
output := CartesianProduct(input)
|
||||
assert.Len(output, 24)
|
||||
|
||||
for _, v := range output {
|
||||
assert.Len(v, 3)
|
||||
|
||||
assert.Contains(v, "foo")
|
||||
assert.Contains(v, "bar")
|
||||
assert.Contains(v, "baz")
|
||||
}
|
||||
|
||||
input = map[string][]any{
|
||||
"foo": {1, 2, 3, 4},
|
||||
"bar": {},
|
||||
"baz": {false, true},
|
||||
}
|
||||
output = CartesianProduct(input)
|
||||
assert.Empty(output)
|
||||
|
||||
input = map[string][]any{}
|
||||
output = CartesianProduct(input)
|
||||
assert.Empty(output)
|
||||
}
|
||||
73
act/common/context_helpers_test.go
Normal file
73
act/common/context_helpers_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func TestDryrunContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if Dryrun(ctx) {
|
||||
t.Fatal("plain context should not be dryrun")
|
||||
}
|
||||
if !Dryrun(WithDryrun(ctx, true)) {
|
||||
t.Fatal("WithDryrun(true) should set dryrun")
|
||||
}
|
||||
if Dryrun(WithDryrun(ctx, false)) {
|
||||
t.Fatal("WithDryrun(false) should clear dryrun")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobErrorContainer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := errors.New("job failed")
|
||||
|
||||
SetJobError(ctx, err)
|
||||
if got := JobError(ctx); got != nil {
|
||||
t.Fatalf("JobError without container = %v, want nil", got)
|
||||
}
|
||||
|
||||
ctx = WithJobErrorContainer(ctx)
|
||||
SetJobError(ctx, err)
|
||||
if got := JobError(ctx); !errors.Is(got, err) {
|
||||
t.Fatalf("JobError = %v, want %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerAndHookContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if Logger(ctx) != logrus.StandardLogger() {
|
||||
t.Fatal("plain context should use standard logger")
|
||||
}
|
||||
if LoggerHook(ctx) != nil {
|
||||
t.Fatal("plain context should not have a logger hook")
|
||||
}
|
||||
|
||||
logger := logrus.New()
|
||||
ctx = WithLogger(ctx, logger)
|
||||
if Logger(ctx) != logger {
|
||||
t.Fatal("WithLogger should set logger")
|
||||
}
|
||||
|
||||
hook := testHook{}
|
||||
ctx = WithLoggerHook(ctx, hook)
|
||||
if LoggerHook(ctx) != hook {
|
||||
t.Fatal("WithLoggerHook should set hook")
|
||||
}
|
||||
}
|
||||
|
||||
type testHook struct{}
|
||||
|
||||
func (testHook) Levels() []logrus.Level {
|
||||
return logrus.AllLevels
|
||||
}
|
||||
|
||||
func (testHook) Fire(*logrus.Entry) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Style is a specific style
|
||||
type Style int
|
||||
|
||||
// Styles
|
||||
const (
|
||||
StyleDoubleLine = iota
|
||||
StyleSingleLine
|
||||
StyleDashedLine
|
||||
StyleNoLine
|
||||
)
|
||||
|
||||
// NewPen creates a new pen
|
||||
func NewPen(style Style, color int) *Pen {
|
||||
bgcolor := 49
|
||||
if os.Getenv("CLICOLOR") == "0" {
|
||||
color = 0
|
||||
bgcolor = 0
|
||||
}
|
||||
return &Pen{
|
||||
style: style,
|
||||
color: color,
|
||||
bgcolor: bgcolor,
|
||||
}
|
||||
}
|
||||
|
||||
type styleDef struct {
|
||||
cornerTL string
|
||||
cornerTR string
|
||||
cornerBL string
|
||||
cornerBR string
|
||||
lineH string
|
||||
lineV string
|
||||
}
|
||||
|
||||
var styleDefs = []styleDef{
|
||||
{"\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"},
|
||||
{"\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"},
|
||||
{"\u250c", "\u2510", "\u2514", "\u2518", "\u254c", "\u254e"},
|
||||
{" ", " ", " ", " ", " ", " "},
|
||||
}
|
||||
|
||||
// Pen struct
|
||||
type Pen struct {
|
||||
style Style
|
||||
color int
|
||||
bgcolor int
|
||||
}
|
||||
|
||||
// Drawing struct
|
||||
type Drawing struct {
|
||||
buf *strings.Builder
|
||||
width int
|
||||
}
|
||||
|
||||
func (p *Pen) drawTopBars(buf io.Writer, labels ...string) {
|
||||
style := styleDefs[p.style]
|
||||
for _, label := range labels {
|
||||
bar := strings.Repeat(style.lineH, len(label)+2)
|
||||
fmt.Fprintf(buf, " ")
|
||||
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||
fmt.Fprintf(buf, "%s%s%s", style.cornerTL, bar, style.cornerTR)
|
||||
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||
}
|
||||
fmt.Fprintf(buf, "\n")
|
||||
}
|
||||
|
||||
func (p *Pen) drawBottomBars(buf io.Writer, labels ...string) {
|
||||
style := styleDefs[p.style]
|
||||
for _, label := range labels {
|
||||
bar := strings.Repeat(style.lineH, len(label)+2)
|
||||
fmt.Fprintf(buf, " ")
|
||||
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||
fmt.Fprintf(buf, "%s%s%s", style.cornerBL, bar, style.cornerBR)
|
||||
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||
}
|
||||
fmt.Fprintf(buf, "\n")
|
||||
}
|
||||
|
||||
func (p *Pen) drawLabels(buf io.Writer, labels ...string) {
|
||||
style := styleDefs[p.style]
|
||||
for _, label := range labels {
|
||||
fmt.Fprintf(buf, " ")
|
||||
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||
fmt.Fprintf(buf, "%s %s %s", style.lineV, label, style.lineV)
|
||||
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||
}
|
||||
fmt.Fprintf(buf, "\n")
|
||||
}
|
||||
|
||||
// DrawArrow between boxes
|
||||
func (p *Pen) DrawArrow() *Drawing {
|
||||
drawing := &Drawing{
|
||||
buf: new(strings.Builder),
|
||||
width: 1,
|
||||
}
|
||||
fmt.Fprintf(drawing.buf, "\x1b[%dm", p.color)
|
||||
fmt.Fprintf(drawing.buf, "\u2b07")
|
||||
fmt.Fprintf(drawing.buf, "\x1b[%dm", 0)
|
||||
return drawing
|
||||
}
|
||||
|
||||
// DrawBoxes to draw boxes
|
||||
func (p *Pen) DrawBoxes(labels ...string) *Drawing {
|
||||
width := 0
|
||||
for _, l := range labels {
|
||||
width += len(l) + 2 + 2 + 1
|
||||
}
|
||||
drawing := &Drawing{
|
||||
buf: new(strings.Builder),
|
||||
width: width,
|
||||
}
|
||||
p.drawTopBars(drawing.buf, labels...)
|
||||
p.drawLabels(drawing.buf, labels...)
|
||||
p.drawBottomBars(drawing.buf, labels...)
|
||||
|
||||
return drawing
|
||||
}
|
||||
|
||||
// Draw to writer
|
||||
func (d *Drawing) Draw(writer io.Writer, centerOnWidth int) {
|
||||
padSize := max((centerOnWidth-d.GetWidth())/2, 0)
|
||||
for l := range strings.SplitSeq(d.buf.String(), "\n") {
|
||||
if len(l) > 0 {
|
||||
padding := strings.Repeat(" ", padSize)
|
||||
fmt.Fprintf(writer, "%s%s\n", padding, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetWidth of drawing
|
||||
func (d *Drawing) GetWidth() int {
|
||||
return d.width
|
||||
}
|
||||
@@ -12,24 +12,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Warning that implements `error` but safe to ignore
|
||||
type Warning struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error the contract for error
|
||||
func (w Warning) Error() string {
|
||||
return w.Message
|
||||
}
|
||||
|
||||
// Warningf create a warning
|
||||
func Warningf(format string, args ...any) Warning {
|
||||
w := Warning{
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Executor define contract for the steps of a workflow
|
||||
type Executor func(ctx context.Context) error
|
||||
|
||||
@@ -97,6 +79,12 @@ func NewErrorExecutor(err error) Executor {
|
||||
|
||||
// NewParallelExecutor creates a new executor from a parallel of other executors
|
||||
func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
||||
if len(executors) == 0 {
|
||||
return func(ctx context.Context) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
work := make(chan Executor, len(executors))
|
||||
errs := make(chan error, len(executors))
|
||||
@@ -156,14 +144,8 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
||||
// Then runs another executor if this executor succeeds
|
||||
func (e Executor) Then(then Executor) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
err := e(ctx)
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
case Warning:
|
||||
Logger(ctx).Warning(err.Error())
|
||||
default:
|
||||
return err
|
||||
}
|
||||
if err := e(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Simple fast test that verifies max-parallel: 2 limits concurrency
|
||||
func TestMaxParallel2Quick(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var currentRunning atomic.Int32
|
||||
var maxSimultaneous atomic.Int32
|
||||
|
||||
executors := make([]Executor, 4)
|
||||
for i := range 4 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
// Update max if needed
|
||||
for {
|
||||
maxValue := maxSimultaneous.Load()
|
||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err := NewParallelExecutor(2, executors...)(ctx)
|
||||
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.LessOrEqual(t, maxSimultaneous.Load(), int32(2),
|
||||
"Should not exceed max-parallel: 2")
|
||||
}
|
||||
|
||||
// Test that verifies max-parallel: 1 enforces sequential execution
|
||||
func TestMaxParallel1Sequential(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var currentRunning atomic.Int32
|
||||
var maxSimultaneous atomic.Int32
|
||||
var executionOrder []int
|
||||
var orderMutex sync.Mutex
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
// Track execution order
|
||||
orderMutex.Lock()
|
||||
executionOrder = append(executionOrder, taskID)
|
||||
orderMutex.Unlock()
|
||||
|
||||
// Update max if needed
|
||||
for {
|
||||
maxValue := maxSimultaneous.Load()
|
||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err := NewParallelExecutor(1, executors...)(ctx)
|
||||
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, int32(1), maxSimultaneous.Load(),
|
||||
"max-parallel: 1 should only run 1 task at a time")
|
||||
assert.Len(t, executionOrder, 5, "All 5 tasks should have executed")
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestMaxParallelJobExecution tests actual job execution with max-parallel
|
||||
func TestMaxParallelJobExecution(t *testing.T) {
|
||||
t.Run("MaxParallel=1 Sequential", func(t *testing.T) {
|
||||
var currentRunning atomic.Int32
|
||||
var maxConcurrent int32
|
||||
var executionOrder []int
|
||||
var mu sync.Mutex
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
// Track max concurrent
|
||||
for {
|
||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
executionOrder = append(executionOrder, taskID)
|
||||
mu.Unlock()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(1, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, int32(1), maxConcurrent, "Should never exceed 1 concurrent execution")
|
||||
assert.Len(t, executionOrder, 5, "All tasks should execute")
|
||||
})
|
||||
|
||||
t.Run("MaxParallel=3 Limited", func(t *testing.T) {
|
||||
var currentRunning atomic.Int32
|
||||
var maxConcurrent int32
|
||||
|
||||
executors := make([]Executor, 10)
|
||||
for i := range 10 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
for {
|
||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(3, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.LessOrEqual(t, int(maxConcurrent), 3, "Should never exceed 3 concurrent executions")
|
||||
assert.GreaterOrEqual(t, int(maxConcurrent), 1, "Should have at least 1 concurrent execution")
|
||||
})
|
||||
|
||||
t.Run("MaxParallel=0 Uses1Worker", func(t *testing.T) {
|
||||
var maxConcurrent int32
|
||||
var currentRunning atomic.Int32
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
current := currentRunning.Add(1)
|
||||
|
||||
for {
|
||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
currentRunning.Add(-1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// When maxParallel is 0 or negative, it defaults to 1
|
||||
err := NewParallelExecutor(0, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, int32(1), maxConcurrent, "Should use 1 worker when max-parallel is 0")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelWithErrors tests error handling with max-parallel
|
||||
func TestMaxParallelWithErrors(t *testing.T) {
|
||||
t.Run("OneTaskFailsOthersContinue", func(t *testing.T) {
|
||||
var successCount int32
|
||||
|
||||
executors := make([]Executor, 5)
|
||||
for i := range 5 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
if taskID == 2 {
|
||||
return assert.AnError
|
||||
}
|
||||
atomic.AddInt32(&successCount, 1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(2, executors...)(ctx)
|
||||
|
||||
// Should return the error from task 2
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Other tasks should still execute
|
||||
assert.Equal(t, int32(4), successCount, "4 tasks should succeed")
|
||||
})
|
||||
|
||||
t.Run("ContextCancellation", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
var startedCount int32
|
||||
executors := make([]Executor, 10)
|
||||
for i := range 10 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
atomic.AddInt32(&startedCount, 1)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel after a short delay
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
err := NewParallelExecutor(3, executors...)(ctx)
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Not all tasks should start due to cancellation (but timing may vary)
|
||||
// Just verify cancellation occurred
|
||||
t.Logf("Started %d tasks before cancellation", startedCount)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelPerformance tests performance characteristics
|
||||
func TestMaxParallelPerformance(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping performance test in short mode")
|
||||
}
|
||||
|
||||
t.Run("ParallelFasterThanSequential", func(t *testing.T) {
|
||||
executors := make([]Executor, 10)
|
||||
for i := range 10 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Sequential (max-parallel=1)
|
||||
start := time.Now()
|
||||
err := NewParallelExecutor(1, executors...)(ctx)
|
||||
sequentialDuration := time.Since(start)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Parallel (max-parallel=5)
|
||||
start = time.Now()
|
||||
err = NewParallelExecutor(5, executors...)(ctx)
|
||||
parallelDuration := time.Since(start)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Parallel should be significantly faster
|
||||
assert.Less(t, parallelDuration, sequentialDuration/2,
|
||||
"Parallel execution should be at least 2x faster")
|
||||
})
|
||||
|
||||
t.Run("OptimalWorkerCount", func(t *testing.T) {
|
||||
executors := make([]Executor, 20)
|
||||
for i := range 20 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with different worker counts
|
||||
workerCounts := []int{1, 2, 5, 10, 20}
|
||||
durations := make(map[int]time.Duration)
|
||||
|
||||
for _, count := range workerCounts {
|
||||
start := time.Now()
|
||||
err := NewParallelExecutor(count, executors...)(ctx)
|
||||
durations[count] = time.Since(start)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
// More workers should generally be faster (up to a point)
|
||||
assert.Less(t, durations[5], durations[1], "5 workers should be faster than 1")
|
||||
assert.Less(t, durations[10], durations[2], "10 workers should be faster than 2")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
||||
func TestMaxParallelResourceSharing(t *testing.T) {
|
||||
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
||||
var sharedCounter int
|
||||
var mu sync.Mutex
|
||||
|
||||
executors := make([]Executor, 100)
|
||||
for i := range 100 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
mu.Lock()
|
||||
sharedCounter++
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(10, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, 100, sharedCounter, "All tasks should increment counter")
|
||||
})
|
||||
|
||||
t.Run("ChannelCommunication", func(t *testing.T) {
|
||||
resultChan := make(chan int, 50)
|
||||
|
||||
executors := make([]Executor, 50)
|
||||
for i := range 50 {
|
||||
taskID := i
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
resultChan <- taskID
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := NewParallelExecutor(5, executors...)(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
close(resultChan)
|
||||
|
||||
results := make(map[int]bool)
|
||||
for result := range resultChan {
|
||||
results[result] = true
|
||||
}
|
||||
|
||||
assert.Len(t, results, 50, "All task IDs should be received")
|
||||
})
|
||||
}
|
||||
@@ -7,11 +7,14 @@ package common
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewWorkflow(t *testing.T) {
|
||||
@@ -79,44 +82,58 @@ func TestNewConditionalExecutor(t *testing.T) {
|
||||
assert.Equal(1, falseCount)
|
||||
}
|
||||
|
||||
func TestNewParallelExecutor(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
// 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) {
|
||||
var counted, active, peak atomic.Int32
|
||||
var once sync.Once
|
||||
reached := make(chan struct{})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var count, activeCount, maxCount atomic.Int32
|
||||
emptyWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
||||
count.Add(1)
|
||||
|
||||
active := activeCount.Add(1)
|
||||
return func(ctx context.Context) error {
|
||||
counted.Add(1)
|
||||
running := active.Add(1)
|
||||
for {
|
||||
m := maxCount.Load()
|
||||
if active <= m || maxCount.CompareAndSwap(m, active) {
|
||||
seen := peak.Load()
|
||||
if running <= seen || peak.CompareAndSwap(seen, running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
activeCount.Add(-1)
|
||||
|
||||
if running >= wantActive {
|
||||
once.Do(func() { close(reached) })
|
||||
}
|
||||
<-reached
|
||||
active.Add(-1)
|
||||
return nil
|
||||
})
|
||||
}, &counted, &peak
|
||||
}
|
||||
|
||||
err := NewParallelExecutor(2, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||
func TestNewParallelExecutor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
exec, count, maxActive := concurrencyProbe(2)
|
||||
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(t, int32(2), maxActive.Load(), "should run at most 2 executors in parallel")
|
||||
|
||||
// Reset to test running the executor with 0 parallelism
|
||||
count.Store(0)
|
||||
activeCount.Store(0)
|
||||
maxCount.Store(0)
|
||||
// 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")
|
||||
}
|
||||
|
||||
errSingle := NewParallelExecutor(0, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
|
||||
assert.NoError(errSingle)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, NewParallelExecutor(2)(ctx))
|
||||
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := NewParallelExecutor(2)(canceledCtx)
|
||||
assert.ErrorIs(err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestNewParallelExecutorFailed(t *testing.T) {
|
||||
@@ -156,3 +173,60 @@ func TestNewParallelExecutorCanceled(t *testing.T) {
|
||||
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...)(context.Background()))
|
||||
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
||||
}
|
||||
|
||||
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var calls []string
|
||||
record := func(name string) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
calls = append(calls, name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, record("if-true").If(func(context.Context) bool { return true })(ctx))
|
||||
require.NoError(t, record("if-false").If(func(context.Context) bool { return false })(ctx))
|
||||
require.NoError(t, record("if-not").IfNot(func(context.Context) bool { return false })(ctx))
|
||||
require.NoError(t, record("if-bool").IfBool(true)(ctx))
|
||||
require.NoError(t, record("main").Finally(record("finally"))(ctx))
|
||||
|
||||
want := []string{"if-true", "if-not", "if-bool", "main", "finally"}
|
||||
if !reflect.DeepEqual(calls, want) {
|
||||
t.Fatalf("calls = %v, want %v", calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
||||
mainErr := errors.New("main failed")
|
||||
finalErr := errors.New("cleanup failed")
|
||||
|
||||
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(context.Background())
|
||||
require.Error(t, err)
|
||||
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
|
||||
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionalNot(t *testing.T) {
|
||||
cond := Conditional(func(context.Context) bool { return false })
|
||||
if !cond.Not()(context.Background()) {
|
||||
t.Fatal("inverted conditional should be true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CopyFile copy file
|
||||
func CopyFile(source, dest string) (err error) {
|
||||
sourcefile, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer sourcefile.Close()
|
||||
|
||||
destfile, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer destfile.Close()
|
||||
|
||||
_, err = io.Copy(destfile, sourcefile)
|
||||
if err == nil {
|
||||
sourceinfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
_ = os.Chmod(dest, sourceinfo.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// CopyDir recursive copy of directory
|
||||
func CopyDir(source, dest string) (err error) {
|
||||
// get properties of source dir
|
||||
sourceinfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create dest dir
|
||||
|
||||
err = os.MkdirAll(dest, sourceinfo.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objects, err := os.ReadDir(source)
|
||||
|
||||
for _, obj := range objects {
|
||||
sourcefilepointer := source + "/" + obj.Name()
|
||||
|
||||
destinationfilepointer := dest + "/" + obj.Name()
|
||||
|
||||
if obj.IsDir() {
|
||||
// create sub-directories - recursively
|
||||
err = CopyDir(sourcefilepointer, destinationfilepointer)
|
||||
if err != nil {
|
||||
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
}
|
||||
} else {
|
||||
// perform copy
|
||||
err = CopyFile(sourcefilepointer, destinationfilepointer)
|
||||
if err != nil {
|
||||
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
@@ -32,7 +33,7 @@ var (
|
||||
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||
|
||||
cloneLocks sync.Map // key: clone target directory; value: *sync.Mutex
|
||||
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||
|
||||
ErrShortRef = errors.New("short SHA references are not supported")
|
||||
ErrNoRepo = errors.New("unable to find git repo")
|
||||
@@ -43,10 +44,7 @@ var (
|
||||
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
||||
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
||||
func AcquireCloneLock(dir string) func() {
|
||||
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
mu := v.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
return cloneLocks.Lock(dir)
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
@@ -66,8 +64,21 @@ func (e *Error) Commit() string {
|
||||
return e.commit
|
||||
}
|
||||
|
||||
// goGitMu serializes go-git repository access across the process. go-git is not safe for
|
||||
// concurrent use of the same repository (even read access decodes packfiles into shared
|
||||
// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded
|
||||
// operations are fast local reads; gitea runs one job per process, so the lock is effectively
|
||||
// uncontended in production.
|
||||
var goGitMu sync.Mutex
|
||||
|
||||
// FindGitRevision get the current git revision
|
||||
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||
goGitMu.Lock()
|
||||
defer goGitMu.Unlock()
|
||||
return findGitRevision(ctx, file)
|
||||
}
|
||||
|
||||
func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
gitDir, err := git.PlainOpenWithOptions(
|
||||
@@ -99,10 +110,13 @@ func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, er
|
||||
|
||||
// FindGitRef get the current git ref
|
||||
func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||
goGitMu.Lock()
|
||||
defer goGitMu.Unlock()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
logger.Debugf("Loading revision from git directory")
|
||||
_, ref, err := FindGitRevision(ctx, file)
|
||||
_, ref, err := findGitRevision(ctx, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -174,6 +188,8 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||
|
||||
// FindGithubRepo get the repo
|
||||
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
||||
goGitMu.Lock()
|
||||
defer goGitMu.Unlock()
|
||||
if remoteName == "" {
|
||||
remoteName = "origin"
|
||||
}
|
||||
@@ -239,51 +255,77 @@ type NewGitCloneExecutorInput struct {
|
||||
Token string
|
||||
OfflineMode bool
|
||||
|
||||
// Depth limits the clone/fetch to the given number of commits from the tip of the requested ref.
|
||||
// 0 for full clone.
|
||||
Depth int
|
||||
|
||||
// Quiet drops the informational clone line to debug level, for callers that log their own
|
||||
// download summary (the setup section's action report).
|
||||
Quiet bool
|
||||
|
||||
// For Gitea
|
||||
InsecureSkipTLS bool
|
||||
}
|
||||
|
||||
// CloneIfRequired ...
|
||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) {
|
||||
// CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused.
|
||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
||||
r, err := git.PlainOpen(input.Dir)
|
||||
if err != nil {
|
||||
var progressWriter io.Writer
|
||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||
if entry, ok := logger.(*log.Entry); ok {
|
||||
progressWriter = entry.WriterLevel(log.DebugLevel)
|
||||
} else if lgr, ok := logger.(*log.Logger); ok {
|
||||
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
||||
} else {
|
||||
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
||||
progressWriter = os.Stdout
|
||||
}
|
||||
if err == nil {
|
||||
// Verify the cached clone still points to the resolved URL before reusing it.
|
||||
remote, err := r.Remote("origin")
|
||||
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
|
||||
// Reuse existing clone
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
cloneOptions := git.CloneOptions{
|
||||
URL: input.URL,
|
||||
Progress: progressWriter,
|
||||
|
||||
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
|
||||
}
|
||||
if input.Token != "" {
|
||||
cloneOptions.Auth = &http.BasicAuth{
|
||||
Username: "token",
|
||||
Password: input.Token,
|
||||
}
|
||||
}
|
||||
|
||||
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
|
||||
if err != nil {
|
||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||
return nil, err
|
||||
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||
} else if len(remote.Config().URLs) == 0 {
|
||||
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
||||
} else {
|
||||
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
||||
}
|
||||
|
||||
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
if err := os.RemoveAll(input.Dir); err != nil {
|
||||
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return r, nil
|
||||
var progressWriter io.Writer
|
||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||
if entry, ok := logger.(*log.Entry); ok {
|
||||
progressWriter = entry.WriterLevel(log.DebugLevel)
|
||||
} else if lgr, ok := logger.(*log.Logger); ok {
|
||||
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
||||
} else {
|
||||
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
||||
progressWriter = os.Stdout
|
||||
}
|
||||
}
|
||||
|
||||
cloneOptions := git.CloneOptions{
|
||||
URL: input.URL,
|
||||
Progress: progressWriter,
|
||||
|
||||
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
|
||||
}
|
||||
if input.Token != "" {
|
||||
cloneOptions.Auth = &http.BasicAuth{
|
||||
Username: "token",
|
||||
Password: input.Token,
|
||||
}
|
||||
}
|
||||
|
||||
r, err = cloneAtDepth(ctx, input, cloneOptions, logger)
|
||||
if err != nil {
|
||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return r, false, nil
|
||||
}
|
||||
|
||||
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
||||
@@ -307,13 +349,17 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
|
||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
if input.Quiet {
|
||||
logger.Debugf("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
} else {
|
||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
}
|
||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||
|
||||
defer AcquireCloneLock(input.Dir)()
|
||||
|
||||
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
||||
r, err := CloneIfRequired(ctx, refName, input, logger)
|
||||
r, reused, err := CloneIfRequired(ctx, refName, input, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -328,6 +374,16 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
pullOptions.InsecureSkipTLS = true
|
||||
}
|
||||
|
||||
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||
shallow := isShallow(r)
|
||||
if shallow {
|
||||
fetchOptions.Depth = 1
|
||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||
}
|
||||
}
|
||||
|
||||
if !isOfflineMode {
|
||||
err = r.Fetch(&fetchOptions)
|
||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
@@ -338,10 +394,10 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
var hash *plumbing.Hash
|
||||
rev := plumbing.Revision(input.Ref)
|
||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||
// ResolveRevision returns a nil hash on error, and a branch ref legitimately fails
|
||||
// here (no local refs/heads/<ref>); the duck-typing below resolves it.
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
}
|
||||
|
||||
if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
|
||||
} else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
|
||||
return &Error{
|
||||
err: ErrShortRef,
|
||||
commit: hash.String(),
|
||||
@@ -392,12 +448,20 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !isOfflineMode {
|
||||
|
||||
reusedMsg := ""
|
||||
|
||||
switch {
|
||||
case !isOfflineMode && !shallow:
|
||||
// In shallow mode the depth-limited fetch above already advanced the ref.
|
||||
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||
}
|
||||
case isOfflineMode && reused:
|
||||
reusedMsg = " (reused in offline mode)"
|
||||
}
|
||||
logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
|
||||
|
||||
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
||||
|
||||
if hash.String() != input.Ref && refType == "branch" {
|
||||
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
||||
@@ -426,3 +490,53 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// cloneAtDepth clones input.URL into input.Dir using opts.
|
||||
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
||||
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||
if input.Depth > 0 {
|
||||
for _, refName := range []plumbing.ReferenceName{
|
||||
plumbing.NewBranchReferenceName(input.Ref),
|
||||
plumbing.NewTagReferenceName(input.Ref),
|
||||
} {
|
||||
shallowOpts := opts
|
||||
shallowOpts.Depth = input.Depth
|
||||
shallowOpts.SingleBranch = true
|
||||
shallowOpts.ReferenceName = refName
|
||||
shallowOpts.Tags = git.NoTags
|
||||
|
||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
||||
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
||||
}
|
||||
}
|
||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||
}
|
||||
|
||||
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||
}
|
||||
|
||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||
func isShallow(r *git.Repository) bool {
|
||||
shallows, err := r.Storer.Shallow()
|
||||
return err == nil && len(shallows) > 0
|
||||
}
|
||||
|
||||
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
||||
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
||||
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||
tagRef := plumbing.NewTagReferenceName(ref)
|
||||
if _, err := r.Reference(tagRef, false); err == nil {
|
||||
return config.RefSpec(fmt.Sprintf("+%s:%s", tagRef, tagRef)), true
|
||||
}
|
||||
remoteRef := plumbing.NewRemoteReferenceName("origin", ref)
|
||||
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -50,8 +53,11 @@ func TestFindGitSlug(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testDir(t *testing.T) string {
|
||||
return t.TempDir()
|
||||
func TestErrorWrapsCommitAndCause(t *testing.T) {
|
||||
err := &Error{err: ErrShortRef, commit: "abc123"}
|
||||
require.Equal(t, ErrShortRef.Error(), err.Error())
|
||||
require.ErrorIs(t, err, ErrShortRef)
|
||||
require.Equal(t, "abc123", err.Commit())
|
||||
}
|
||||
|
||||
func cleanGitHooks(dir string) error {
|
||||
@@ -78,8 +84,7 @@ func cleanGitHooks(dir string) error {
|
||||
func TestFindGitRemoteURL(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
basedir := testDir(t)
|
||||
gitConfig()
|
||||
basedir := t.TempDir()
|
||||
err := gitCmd("init", basedir)
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
err = cleanGitHooks(basedir)
|
||||
@@ -101,9 +106,24 @@ func TestFindGitRemoteURL(t *testing.T) {
|
||||
assert.Equal(remoteURL, u)
|
||||
}
|
||||
|
||||
func TestFindGithubRepoUsesOriginAndCustomRemote(t *testing.T) {
|
||||
basedir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", basedir))
|
||||
require.NoError(t, cleanGitHooks(basedir))
|
||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", "https://github.com/owner/repo.git"))
|
||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "ghe", "git@git.example.com:team/project.git"))
|
||||
|
||||
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "owner/repo", slug)
|
||||
|
||||
slug, err = FindGithubRepo(context.Background(), basedir, "git.example.com", "ghe")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "team/project", slug)
|
||||
}
|
||||
|
||||
func TestGitFindRef(t *testing.T) {
|
||||
basedir := testDir(t)
|
||||
gitConfig()
|
||||
basedir := t.TempDir()
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Prepare func(t *testing.T, dir string)
|
||||
@@ -180,36 +200,55 @@ func TestGitFindRef(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGitCloneExecutor(t *testing.T) {
|
||||
// Build a local bare "remote" so this runs offline and fast. The cases below mirror
|
||||
// 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, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
||||
|
||||
// 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, "commit", "--allow-empty", "-m", "branch-commit"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
|
||||
|
||||
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
|
||||
require.NoError(t, err)
|
||||
fullSha := strings.TrimSpace(string(out))
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Err error
|
||||
URL, Ref string
|
||||
Err error
|
||||
Ref string
|
||||
}{
|
||||
"tag": {
|
||||
Err: nil,
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "v2",
|
||||
},
|
||||
"branch": {
|
||||
Err: nil,
|
||||
URL: "https://github.com/anchore/scan-action",
|
||||
Ref: "act-fails",
|
||||
},
|
||||
"sha": {
|
||||
Err: nil,
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
|
||||
Ref: fullSha,
|
||||
},
|
||||
"short-sha": {
|
||||
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"},
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "5a4ac90", // v2
|
||||
Err: &Error{ErrShortRef, fullSha},
|
||||
Ref: fullSha[:7],
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: tt.URL,
|
||||
URL: remoteDir,
|
||||
Ref: tt.Ref,
|
||||
Dir: testDir(t),
|
||||
Dir: t.TempDir(),
|
||||
})
|
||||
|
||||
err := clone(context.Background())
|
||||
@@ -223,13 +262,56 @@ func TestGitCloneExecutor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||
createRemote := func(message string) string {
|
||||
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", message))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
|
||||
return remoteDir
|
||||
}
|
||||
|
||||
oldRemoteDir := createRemote("old-action")
|
||||
newRemoteDir := createRemote("new-action")
|
||||
cacheDir := t.TempDir()
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: oldRemoteDir,
|
||||
Ref: "main",
|
||||
Dir: cacheDir,
|
||||
})(t.Context()))
|
||||
|
||||
markerPath := filepath.Join(cacheDir, "stale-marker")
|
||||
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: newRemoteDir,
|
||||
Ref: "main",
|
||||
Dir: cacheDir,
|
||||
})(t.Context()))
|
||||
|
||||
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newRemoteDir, originURL)
|
||||
|
||||
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-action", strings.TrimSpace(string(out)))
|
||||
|
||||
_, err = os.Stat(markerPath)
|
||||
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
||||
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
|
||||
|
||||
gitConfig()
|
||||
|
||||
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
@@ -279,22 +361,195 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
|
||||
}
|
||||
|
||||
func gitConfig() {
|
||||
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||
var err error
|
||||
if err = gitCmd("config", "--global", "user.email", "test@test.com"); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||
// Build a local "remote" with a single commit on main.
|
||||
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"))
|
||||
|
||||
// Prime the cache with an online clone of main.
|
||||
cacheDir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir,
|
||||
Ref: "main",
|
||||
Dir: cacheDir,
|
||||
})(context.Background()))
|
||||
|
||||
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{
|
||||
URL: remoteDir,
|
||||
Ref: "main",
|
||||
Dir: cacheDir,
|
||||
OfflineMode: true,
|
||||
})(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "initial", strings.TrimSpace(string(out)))
|
||||
})
|
||||
|
||||
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{
|
||||
URL: remoteDir,
|
||||
Ref: "never-fetched",
|
||||
Dir: cacheDir,
|
||||
OfflineMode: true,
|
||||
})(context.Background())
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
|
||||
// Quiet callers report the download themselves, so the clone line must not reach the job log.
|
||||
for name, quiet := range map[string]bool{"quiet": true, "not quiet": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
logger.SetLevel(log.InfoLevel)
|
||||
ctx := common.WithLogger(context.Background(), logger.WithField("job", "j1"))
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir,
|
||||
Ref: "main",
|
||||
Dir: t.TempDir(),
|
||||
Quiet: quiet,
|
||||
})(ctx))
|
||||
|
||||
var cloneLines int
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if strings.HasPrefix(entry.Message, "git clone ") {
|
||||
cloneLines++
|
||||
}
|
||||
}
|
||||
if quiet {
|
||||
assert.Zero(t, cloneLines)
|
||||
} else {
|
||||
assert.Equal(t, 1, cloneLines)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
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"))
|
||||
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||
|
||||
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
||||
|
||||
t.Run("branch is cloned shallowly", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||
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))
|
||||
})
|
||||
|
||||
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
||||
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
|
||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||
|
||||
// Advance main on the remote, then reuse the existing shallow clone.
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
||||
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||
})
|
||||
}
|
||||
|
||||
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||
require.NoError(t, err)
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func gitRevCount(t *testing.T, dir string) int {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
|
||||
require.NoError(t, err)
|
||||
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
func gitHeadSubject(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%s").Output()
|
||||
require.NoError(t, err)
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func gitCmd(args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
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(),
|
||||
"GIT_AUTHOR_NAME=Unit Test",
|
||||
"GIT_AUTHOR_EMAIL=test@test.com",
|
||||
"GIT_COMMITTER_NAME=Unit Test",
|
||||
"GIT_COMMITTER_EMAIL=test@test.com",
|
||||
"GIT_CONFIG_GLOBAL=/dev/null",
|
||||
"GIT_CONFIG_SYSTEM=/dev/null",
|
||||
)
|
||||
|
||||
err := cmd.Run()
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
@@ -354,12 +609,4 @@ func TestAcquireCloneLock(t *testing.T) {
|
||||
t.Fatal("acquire on a different directory must not block")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same directory reuses the same mutex", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
v1, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
v2, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||
require.Same(t, v1, v2)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ func JobError(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func SetJobError(ctx context.Context, err error) {
|
||||
ctx.Value(jobErrorContextKeyVal).(map[string]error)["error"] = err
|
||||
if container, ok := ctx.Value(jobErrorContextKeyVal).(map[string]error); ok {
|
||||
container["error"] = err
|
||||
}
|
||||
}
|
||||
|
||||
// WithJobErrorContainer adds a value to the context as a container for an error
|
||||
|
||||
@@ -12,6 +12,13 @@ import (
|
||||
// LineHandler is a callback function for handling a line
|
||||
type LineHandler func(line string) bool
|
||||
|
||||
// Flusher is implemented by writers that buffer a trailing, not-yet-terminated
|
||||
// line. Callers should flush once the underlying stream has reached EOF so the
|
||||
// final line (when it is not newline-terminated) is not lost.
|
||||
type Flusher interface {
|
||||
Flush()
|
||||
}
|
||||
|
||||
type lineWriter struct {
|
||||
buffer bytes.Buffer
|
||||
handlers []LineHandler
|
||||
@@ -24,6 +31,14 @@ func NewLineWriter(handlers ...LineHandler) io.Writer {
|
||||
return w
|
||||
}
|
||||
|
||||
// FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so
|
||||
// callers can flush an io.Writer without knowing its concrete type.
|
||||
func FlushWriter(w io.Writer) {
|
||||
if f, ok := w.(Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||
pBuf := bytes.NewBuffer(p)
|
||||
written := 0
|
||||
@@ -44,6 +59,17 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// Flush emits any buffered, not-yet-newline-terminated content as a final line.
|
||||
// It is safe to call multiple times; subsequent calls with an empty buffer are
|
||||
// no-ops.
|
||||
func (lw *lineWriter) Flush() {
|
||||
if lw.buffer.Len() == 0 {
|
||||
return
|
||||
}
|
||||
lw.handleLine(lw.buffer.String())
|
||||
lw.buffer.Reset()
|
||||
}
|
||||
|
||||
func (lw *lineWriter) handleLine(line string) {
|
||||
for _, h := range lw.handlers {
|
||||
ok := h(line)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -39,3 +40,33 @@ func TestLineWriter(t *testing.T) {
|
||||
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)
|
||||
assert.Equal([]string{"complete line\n", "partial line without newline"}, lines)
|
||||
|
||||
// Flushing again is a no-op: nothing is buffered.
|
||||
FlushWriter(lineWriter)
|
||||
assert.Len(lines, 2)
|
||||
}
|
||||
|
||||
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) })
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ func GetOutboundIP() net.IP {
|
||||
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||
if err == nil {
|
||||
defer conn.Close()
|
||||
return conn.LocalAddr().(*net.UDPAddr).IP
|
||||
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||
return addr.IP
|
||||
}
|
||||
}
|
||||
|
||||
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||
|
||||
@@ -47,6 +47,7 @@ type NewContainerInput struct {
|
||||
// Gitea specific
|
||||
AutoRemove bool
|
||||
ValidVolumes []string
|
||||
AllocatePTY bool // allocate a pseudo-TTY for the container's exec processes
|
||||
}
|
||||
|
||||
// FileEntry is a file to copy to a container
|
||||
@@ -81,6 +82,14 @@ type NewDockerBuildExecutorInput struct {
|
||||
BuildContext io.Reader
|
||||
ImageTag string
|
||||
Platform string
|
||||
BuildArgs map[string]*string
|
||||
}
|
||||
|
||||
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
||||
type NewDockerNetworkCreateExecutorInput struct {
|
||||
EnableIPv4 *bool
|
||||
EnableIPv6 *bool
|
||||
RunnerUUID string
|
||||
}
|
||||
|
||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||
|
||||
@@ -8,34 +8,33 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/moby/moby/api/types/registry"
|
||||
)
|
||||
|
||||
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
||||
logger := common.Logger(ctx)
|
||||
config, err := config.Load(config.Dir())
|
||||
// config.LoadDefaultConfigFile panics on nil io.Writer when the config
|
||||
// file is malformed; use config.Load to route errors through the logger.
|
||||
cfg, err := config.Load(config.Dir())
|
||||
if err != nil {
|
||||
logger.Warnf("Could not load docker config: %v", err)
|
||||
return registry.AuthConfig{}, err
|
||||
}
|
||||
|
||||
if !config.ContainsAuth() {
|
||||
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
||||
registryKey := registryAuthConfigKey("docker.io")
|
||||
if image != "" {
|
||||
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
|
||||
logger.Warnf("Could not normalize image reference: %v", refErr)
|
||||
} else {
|
||||
registryKey = registryAuthConfigKey(reference.Domain(registryRef))
|
||||
}
|
||||
}
|
||||
|
||||
hostName := "index.docker.io"
|
||||
index := strings.IndexRune(image, '/')
|
||||
if index > -1 && (strings.ContainsAny(image[:index], ".:") || image[:index] == "localhost") {
|
||||
hostName = image[:index]
|
||||
}
|
||||
|
||||
authConfig, err := config.GetAuthConfig(hostName)
|
||||
authConfig, err := cfg.GetAuthConfig(registryKey)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not get auth config from docker config: %v", err)
|
||||
return registry.AuthConfig{}, err
|
||||
@@ -46,17 +45,16 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi
|
||||
|
||||
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||
logger := common.Logger(ctx)
|
||||
config, err := config.Load(config.Dir())
|
||||
cfg, err := config.Load(config.Dir())
|
||||
if err != nil {
|
||||
logger.Warnf("Could not load docker config: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !config.ContainsAuth() {
|
||||
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
||||
creds, err := cfg.GetAllCredentials()
|
||||
if err != nil {
|
||||
logger.Warnf("Could not get docker auth configs: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
creds, _ := config.GetAllCredentials()
|
||||
authConfigs := make(map[string]registry.AuthConfig, len(creds))
|
||||
for k, v := range creds {
|
||||
authConfigs[k] = registry.AuthConfig(v)
|
||||
@@ -64,3 +62,10 @@ func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||
|
||||
return authConfigs
|
||||
}
|
||||
|
||||
func registryAuthConfigKey(domainName string) string {
|
||||
if domainName == "docker.io" || domainName == "index.docker.io" {
|
||||
return "https://index.docker.io/v1/"
|
||||
}
|
||||
return domainName
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/pkg/archive"
|
||||
"github.com/moby/buildkit/frontend/dockerfile/dockerignore"
|
||||
"github.com/moby/go-archive"
|
||||
"github.com/moby/go-archive/compression"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/moby/patternmatcher"
|
||||
"github.com/moby/patternmatcher/ignorefile"
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// NewDockerBuildExecutor function to create a run executor for the container
|
||||
@@ -42,12 +44,19 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
logger.Debugf("Building image from '%v'", input.ContextDir)
|
||||
|
||||
tags := []string{input.ImageTag}
|
||||
options := types.ImageBuildOptions{
|
||||
options := client.ImageBuildOptions{
|
||||
Tags: tags,
|
||||
Remove: true,
|
||||
Platform: input.Platform,
|
||||
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||
Dockerfile: input.Dockerfile,
|
||||
BuildArgs: input.BuildArgs,
|
||||
}
|
||||
platform, err := parsePlatform(input.Platform)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if platform != nil {
|
||||
options.Platforms = []specs.Platform{*platform}
|
||||
}
|
||||
var buildContext io.ReadCloser
|
||||
if input.BuildContext != nil {
|
||||
@@ -76,7 +85,7 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
||||
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
||||
|
||||
// And canonicalize dockerfile name to a platform-independent one
|
||||
relDockerfile = archive.CanonicalTarNameForPath(relDockerfile)
|
||||
relDockerfile = filepath.ToSlash(relDockerfile)
|
||||
|
||||
f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
@@ -86,7 +95,7 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
||||
|
||||
var excludes []string
|
||||
if err == nil {
|
||||
excludes, err = dockerignore.ReadAll(f) //nolint:staticcheck // pre-existing issue from nektos/act
|
||||
excludes, err = ignorefile.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -106,9 +115,8 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
||||
includes = append(includes, ".dockerignore", relDockerfile)
|
||||
}
|
||||
|
||||
compression := archive.Uncompressed
|
||||
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
||||
Compression: compression,
|
||||
Compression: compression.None,
|
||||
ExcludePatterns: excludes,
|
||||
IncludeFiles: includes,
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,15 +16,18 @@ package container
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
networktypes "github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
networktypes "github.com/moby/moby/api/types/network"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/pflag"
|
||||
"gotest.tools/v3/assert"
|
||||
@@ -77,21 +80,21 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
||||
return flags, copts
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig) {
|
||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
||||
t.Helper()
|
||||
config, hostConfig, _, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||
config, hostConfig, networkingConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||
assert.NilError(t, err)
|
||||
return config, hostConfig
|
||||
return config, hostConfig, networkingConfig
|
||||
}
|
||||
|
||||
func TestParseRunLinks(t *testing.T) {
|
||||
if _, hostConfig := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
|
||||
if _, hostConfig, _ := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
|
||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links)
|
||||
}
|
||||
if _, hostConfig := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
|
||||
if _, hostConfig, _ := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
|
||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links)
|
||||
}
|
||||
if _, hostConfig := mustParse(t, ""); len(hostConfig.Links) != 0 {
|
||||
if _, hostConfig, _ := mustParse(t, ""); len(hostConfig.Links) != 0 {
|
||||
t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links)
|
||||
}
|
||||
}
|
||||
@@ -140,7 +143,7 @@ func TestParseRunAttach(t *testing.T) {
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
config, _ := mustParse(t, tc.input)
|
||||
config, _, _ := mustParse(t, tc.input)
|
||||
assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin)
|
||||
assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout)
|
||||
assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr)
|
||||
@@ -194,10 +197,10 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWithVolumes(t *testing.T) {
|
||||
func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from docker/cli tests
|
||||
// A single volume
|
||||
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
|
||||
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil {
|
||||
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
|
||||
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 {
|
||||
t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes)
|
||||
@@ -205,7 +208,7 @@ func TestParseWithVolumes(t *testing.T) {
|
||||
|
||||
// Two volumes
|
||||
arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`})
|
||||
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil {
|
||||
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
|
||||
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 {
|
||||
t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes)
|
||||
@@ -215,13 +218,13 @@ func TestParseWithVolumes(t *testing.T) {
|
||||
|
||||
// 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] {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -230,26 +233,26 @@ func TestParseWithVolumes(t *testing.T) {
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{})
|
||||
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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] {
|
||||
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)
|
||||
@@ -258,7 +261,7 @@ func TestParseWithVolumes(t *testing.T) {
|
||||
// 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 {
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -294,6 +297,36 @@ func compareRandomizedStrings(a, b, c, d string) error {
|
||||
return errors.Errorf("strings don't match")
|
||||
}
|
||||
|
||||
func mustNetworkPort(t *testing.T, value string) networktypes.Port {
|
||||
t.Helper()
|
||||
|
||||
port, err := networktypes.ParsePort(value)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse network port %q: %v", value, err)
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func mustAddr(t *testing.T, value string) netip.Addr {
|
||||
t.Helper()
|
||||
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse address %q: %v", value, err)
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func mustAddrs(t *testing.T, values ...string) []netip.Addr {
|
||||
t.Helper()
|
||||
|
||||
addrs := make([]netip.Addr, 0, len(values))
|
||||
for _, value := range values {
|
||||
addrs = append(addrs, mustAddr(t, value))
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
// Simple parse with MacAddress validation
|
||||
func TestParseWithMacAddress(t *testing.T) {
|
||||
invalidMacAddress := "--mac-address=invalidMacAddress"
|
||||
@@ -301,9 +334,10 @@ func TestParseWithMacAddress(t *testing.T) {
|
||||
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
||||
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
||||
}
|
||||
if config, _ := mustParse(t, validMacAddress); config.MacAddress != "92:d0:c6:0a:29:33" { //nolint:staticcheck // pre-existing issue from nektos/act
|
||||
t.Fatalf("Expected the config to have '92:d0:c6:0a:29:33' as MacAddress, got '%v'", config.MacAddress) //nolint:staticcheck // pre-existing issue from nektos/act
|
||||
}
|
||||
_, hostConfig, networkingConfig := mustParse(t, validMacAddress)
|
||||
endpoint := networkingConfig.EndpointsConfig[string(hostConfig.NetworkMode)]
|
||||
assert.Check(t, endpoint != nil)
|
||||
assert.Equal(t, "92:d0:c6:0a:29:33", endpoint.MacAddress.String())
|
||||
}
|
||||
|
||||
func TestRunFlagsParseWithMemory(t *testing.T) {
|
||||
@@ -312,7 +346,7 @@ func TestRunFlagsParseWithMemory(t *testing.T) {
|
||||
err := flags.Parse(args)
|
||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
|
||||
|
||||
_, hostconfig := mustParse(t, "--memory=1G")
|
||||
_, hostconfig, _ := mustParse(t, "--memory=1G")
|
||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory))
|
||||
}
|
||||
|
||||
@@ -322,10 +356,10 @@ func TestParseWithMemorySwap(t *testing.T) {
|
||||
err := flags.Parse(args)
|
||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
|
||||
|
||||
_, hostconfig := mustParse(t, "--memory-swap=1G")
|
||||
_, hostconfig, _ := mustParse(t, "--memory-swap=1G")
|
||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap))
|
||||
|
||||
_, hostconfig = mustParse(t, "--memory-swap=-1")
|
||||
_, hostconfig, _ = mustParse(t, "--memory-swap=-1")
|
||||
assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap))
|
||||
}
|
||||
|
||||
@@ -340,14 +374,14 @@ func TestParseHostname(t *testing.T) {
|
||||
hostnameWithDomain := "--hostname=hostname.domainname"
|
||||
hostnameWithDomainTld := "--hostname=hostname.domainname.tld"
|
||||
for hostname, expectedHostname := range validHostnames {
|
||||
if config, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname {
|
||||
if config, _, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname {
|
||||
t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname)
|
||||
}
|
||||
}
|
||||
if config, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" {
|
||||
if config, _, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" {
|
||||
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname)
|
||||
}
|
||||
if config, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -361,26 +395,28 @@ func TestParseHostnameDomainname(t *testing.T) {
|
||||
"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 {
|
||||
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" {
|
||||
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" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWithExpose(t *testing.T) {
|
||||
invalids := map[string]string{
|
||||
":": "invalid port format for --expose: :",
|
||||
"8080:9090": "invalid port format for --expose: 8080:9090",
|
||||
"NaN/tcp": `invalid range format for --expose: NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
||||
"NaN-NaN/tcp": `invalid range format for --expose: NaN-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
||||
"8080-NaN/tcp": `invalid range format for --expose: 8080-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
||||
"1234567890-8080/tcp": `invalid range format for --expose: 1234567890-8080/tcp, error: strconv.ParseUint: parsing "1234567890": value out of range`,
|
||||
invalids := []string{
|
||||
":",
|
||||
"8080:9090",
|
||||
"/tcp",
|
||||
"/udp",
|
||||
"NaN/tcp",
|
||||
"NaN-NaN/tcp",
|
||||
"8080-NaN/tcp",
|
||||
"1234567890-8080/tcp",
|
||||
}
|
||||
valids := map[string][]nat.Port{
|
||||
"8080/tcp": {"8080/tcp"},
|
||||
@@ -389,9 +425,9 @@ func TestParseWithExpose(t *testing.T) {
|
||||
"8080-8080/udp": {"8080/udp"},
|
||||
"8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"},
|
||||
}
|
||||
for expose, expectedError := range invalids {
|
||||
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil || err.Error() != expectedError {
|
||||
t.Fatalf("Expected error '%v' with '--expose=%v', got '%v'", expectedError, expose, err)
|
||||
for _, expose := range invalids {
|
||||
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil {
|
||||
t.Fatalf("Expected error with '--expose=%v', got none", expose)
|
||||
}
|
||||
}
|
||||
for expose, exposedPorts := range valids {
|
||||
@@ -403,7 +439,7 @@ func TestParseWithExpose(t *testing.T) {
|
||||
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
|
||||
}
|
||||
for _, port := range exposedPorts {
|
||||
if _, ok := config.ExposedPorts[port]; !ok {
|
||||
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
||||
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
|
||||
}
|
||||
}
|
||||
@@ -418,7 +454,7 @@ func TestParseWithExpose(t *testing.T) {
|
||||
}
|
||||
ports := []nat.Port{"80/tcp", "81/tcp"}
|
||||
for _, port := range ports {
|
||||
if _, ok := config.ExposedPorts[port]; !ok {
|
||||
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
||||
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
|
||||
}
|
||||
}
|
||||
@@ -462,6 +498,79 @@ func TestParseDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeviceByServerOS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
device string
|
||||
serverOS string
|
||||
want container.DeviceMapping
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "linux source only",
|
||||
device: "/dev/snd",
|
||||
serverOS: "linux",
|
||||
want: container.DeviceMapping{
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/dev/snd",
|
||||
CgroupPermissions: "rwm",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux source and mode",
|
||||
device: "/dev/snd:rw",
|
||||
serverOS: "linux",
|
||||
want: container.DeviceMapping{
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/dev/snd",
|
||||
CgroupPermissions: "rw",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux source target and mode",
|
||||
device: "/dev/snd:/container/snd:m",
|
||||
serverOS: "linux",
|
||||
want: container.DeviceMapping{
|
||||
PathOnHost: "/dev/snd",
|
||||
PathInContainer: "/container/snd",
|
||||
CgroupPermissions: "m",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "windows passes value through",
|
||||
device: `class/GUID`,
|
||||
serverOS: "windows",
|
||||
want: container.DeviceMapping{
|
||||
PathOnHost: `class/GUID`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid server OS",
|
||||
device: "/dev/snd",
|
||||
serverOS: "plan9",
|
||||
wantErr: "unknown server OS: plan9",
|
||||
},
|
||||
{
|
||||
name: "too many linux fields",
|
||||
device: "/dev/snd:/container/snd:rw:extra",
|
||||
serverOS: "linux",
|
||||
wantErr: "invalid device specification: /dev/snd:/container/snd:rw:extra",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseDevice(tc.device, tc.serverOS)
|
||||
if tc.wantErr != "" {
|
||||
assert.Error(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetworkConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -498,9 +607,9 @@ func TestParseNetworkConfig(t *testing.T) {
|
||||
expected: map[string]*networktypes.EndpointSettings{
|
||||
"net1": {
|
||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||
IPv4Address: "172.20.88.22",
|
||||
IPv6Address: "2001:db8::8822",
|
||||
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
||||
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
|
||||
},
|
||||
Links: []string{"foo:bar", "bar:baz"},
|
||||
Aliases: []string{"web1", "web2"},
|
||||
@@ -527,9 +636,9 @@ func TestParseNetworkConfig(t *testing.T) {
|
||||
"net1": {
|
||||
DriverOpts: map[string]string{"field1": "value1"},
|
||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||
IPv4Address: "172.20.88.22",
|
||||
IPv6Address: "2001:db8::8822",
|
||||
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
||||
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
|
||||
},
|
||||
Links: []string{"foo:bar", "bar:baz"},
|
||||
Aliases: []string{"web1", "web2"},
|
||||
@@ -538,8 +647,8 @@ func TestParseNetworkConfig(t *testing.T) {
|
||||
"net3": {
|
||||
DriverOpts: map[string]string{"field3": "value3"},
|
||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||
IPv4Address: "172.20.88.22",
|
||||
IPv6Address: "2001:db8::8822",
|
||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
||||
},
|
||||
Aliases: []string{"web3"},
|
||||
},
|
||||
@@ -556,8 +665,8 @@ func TestParseNetworkConfig(t *testing.T) {
|
||||
"field2": "value2",
|
||||
},
|
||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||
IPv4Address: "172.20.88.22",
|
||||
IPv6Address: "2001:db8::8822",
|
||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
||||
},
|
||||
Aliases: []string{"web1", "web2"},
|
||||
},
|
||||
@@ -610,7 +719,9 @@ func TestParseNetworkConfig(t *testing.T) {
|
||||
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode)
|
||||
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected)
|
||||
if diff := cmp.Diff(tc.expected, nwConfig.EndpointsConfig, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
|
||||
t.Fatalf("unexpected endpoints (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -631,7 +742,7 @@ func TestParseModes(t *testing.T) {
|
||||
}
|
||||
|
||||
// uts ko
|
||||
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"})
|
||||
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
||||
|
||||
// uts ok
|
||||
@@ -691,10 +802,9 @@ func TestParseRestartPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||
expected := "Conflicting options: --restart and --rm"
|
||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"})
|
||||
if err == nil || err.Error() != expected {
|
||||
t.Fatalf("Expected error %v, but got none", expected)
|
||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for conflicting --restart and --rm, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,7 +862,7 @@ func TestParseLoggingOpts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act
|
||||
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||
e := "open nonexistent: no such file or directory"
|
||||
if runtime.GOOS == "windows" {
|
||||
e = "open nonexistent: The system cannot find the file specified."
|
||||
@@ -795,7 +905,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
||||
}
|
||||
|
||||
// UTF16 with BOM
|
||||
e := "contains invalid utf8 bytes at line"
|
||||
e := "invalid env file"
|
||||
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
||||
}
|
||||
@@ -805,7 +915,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act
|
||||
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||
e := "open nonexistent: no such file or directory"
|
||||
if runtime.GOOS == "windows" {
|
||||
e = "open nonexistent: The system cannot find the file specified."
|
||||
@@ -893,6 +1003,82 @@ func TestValidateDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeviceByServerOS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
serverOS string
|
||||
want string
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "linux preserves three-field container path",
|
||||
value: "/host:/container/../device:rw",
|
||||
serverOS: "linux",
|
||||
want: "/host:/container/../device:rw",
|
||||
},
|
||||
{
|
||||
name: "linux source path can be relative when target is absolute",
|
||||
value: "relative-host:/container/device",
|
||||
serverOS: "linux",
|
||||
want: "relative-host:/container/device",
|
||||
},
|
||||
{
|
||||
name: "windows defers validation",
|
||||
value: `class/GUID`,
|
||||
serverOS: "windows",
|
||||
want: `class/GUID`,
|
||||
},
|
||||
{
|
||||
name: "linux rejects bad mode",
|
||||
value: "/host:/container:ro",
|
||||
serverOS: "linux",
|
||||
wantError: "bad mode specified: ro",
|
||||
},
|
||||
{
|
||||
name: "linux target must be absolute",
|
||||
value: "/host:relative",
|
||||
serverOS: "linux",
|
||||
wantError: "relative is not an absolute path",
|
||||
},
|
||||
{
|
||||
name: "unknown server OS",
|
||||
value: "/dev/snd",
|
||||
serverOS: "plan9",
|
||||
wantError: "unknown server OS: plan9",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := validateDevice(tc.value, tc.serverOS)
|
||||
if tc.wantError != "" {
|
||||
assert.Error(t, err, tc.wantError)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
||||
got, err := validateDeviceCgroupRule("c 1:3 rwm")
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got, "c 1:3 rwm")
|
||||
|
||||
_, err = validateDeviceCgroupRule("invalid")
|
||||
assert.Error(t, err, "invalid device cgroup format 'invalid'")
|
||||
|
||||
if invalidParameter(nil) != nil {
|
||||
t.Fatal("invalidParameter(nil) should be nil")
|
||||
}
|
||||
err = invalidParameter(errors.New("bad input"))
|
||||
assert.Assert(t, err != nil)
|
||||
var invalid interface{ InvalidParameter() }
|
||||
assert.Assert(t, errors.As(err, &invalid))
|
||||
}
|
||||
|
||||
func TestParseSystemPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
doc string
|
||||
|
||||
86
act/container/docker_create_flags.go
Normal file
86
act/container/docker_create_flags.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const (
|
||||
pullPolicyAlways = "always"
|
||||
pullPolicyMissing = "missing"
|
||||
pullPolicyNever = "never"
|
||||
)
|
||||
|
||||
var pullPolicies = []string{pullPolicyAlways, pullPolicyMissing, pullPolicyNever}
|
||||
|
||||
// createFlags are the flags docker/cli registers on the `create` and `run` commands
|
||||
// instead of in addFlags, so they are not part of containerOptions.
|
||||
type createFlags struct {
|
||||
platform string
|
||||
pull string
|
||||
name string
|
||||
useAPISocket bool
|
||||
}
|
||||
|
||||
func registerCreateFlags(flags *pflag.FlagSet) *createFlags {
|
||||
cf := new(createFlags)
|
||||
flags.StringVar(&cf.platform, "platform", "", "Set platform if server is multi-platform capable")
|
||||
flags.StringVar(&cf.pull, "pull", pullPolicyMissing, `Pull image before creating ("always", "missing", "never")`)
|
||||
flags.StringVar(&cf.name, "name", "", "Assign a name to the container")
|
||||
flags.BoolVar(&cf.useAPISocket, "use-api-socket", false, "Bind mount Docker API socket and required auth")
|
||||
// Accepted without effect: pull progress is only logged at debug level, and docker
|
||||
// no longer implements content trust.
|
||||
flags.BoolP("quiet", "q", false, "Suppress the pull output")
|
||||
flags.Bool("disable-content-trust", true, "Skip image verification (deprecated)")
|
||||
return cf
|
||||
}
|
||||
|
||||
// parseContainerOptions parses a container options string. The flags are returned even
|
||||
// on error, holding whatever was read before the failure.
|
||||
func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *createFlags, error) {
|
||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
copts := addFlags(flags)
|
||||
cf := registerCreateFlags(flags)
|
||||
|
||||
args, err := shellquote.Split(options)
|
||||
if err != nil {
|
||||
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
|
||||
}
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
|
||||
}
|
||||
|
||||
return flags, copts, cf, nil
|
||||
}
|
||||
|
||||
// createFlagsFromOptions reads the create-level flags that have to be known before the
|
||||
// container is created. Malformed options keep the defaults here and are reported by
|
||||
// mergeContainerConfigs at create time.
|
||||
func createFlagsFromOptions(options string) *createFlags {
|
||||
_, _, cf, _ := parseContainerOptions(options)
|
||||
return cf
|
||||
}
|
||||
|
||||
func (cf *createFlags) validate() error {
|
||||
if !slices.Contains(pullPolicies, cf.pull) {
|
||||
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
|
||||
}
|
||||
|
||||
if cf.useAPISocket {
|
||||
return errors.New("--use-api-socket is not supported, use the runner's container.docker_host setting to expose a docker socket")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
62
act/container/docker_create_flags_test.go
Normal file
62
act/container/docker_create_flags_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateFlagsFromOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
options string
|
||||
platform string
|
||||
pull string
|
||||
}{
|
||||
{"", "", pullPolicyMissing},
|
||||
{"-v /a:/b --platform=linux/arm64 --pull always", "linux/arm64", pullPolicyAlways},
|
||||
{"--platform linux/arm/v7 --pull never", "linux/arm/v7", pullPolicyNever},
|
||||
{`--platform "linux/amd64`, "", pullPolicyMissing}, // malformed, defaults kept
|
||||
} {
|
||||
t.Run(tc.options, func(t *testing.T) {
|
||||
cf := createFlagsFromOptions(tc.options)
|
||||
assert.Equal(t, tc.platform, cf.platform)
|
||||
assert.Equal(t, tc.pull, cf.pull)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFlagsValidate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
options string
|
||||
wantErr string
|
||||
}{
|
||||
{"--quiet --disable-content-trust --name mine", ""},
|
||||
{"--pull sometimes", `invalid --pull option "sometimes"`},
|
||||
{"--use-api-socket", "--use-api-socket is not supported"},
|
||||
} {
|
||||
t.Run(tc.options, func(t *testing.T) {
|
||||
err := createFlagsFromOptions(tc.options).validate()
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
||||
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
||||
cr, ok := NewContainer(input).(*containerReference)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "linux/arm64", input.Platform)
|
||||
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||
|
||||
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
|
||||
NewContainer(kept)
|
||||
assert.Equal(t, "linux/amd64", kept.Platform)
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/client"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||
@@ -23,8 +23,8 @@ func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool,
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName)
|
||||
if client.IsErrNotFound(err) {
|
||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
@@ -46,14 +46,14 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName)
|
||||
if client.IsErrNotFound(err) {
|
||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, types.ImageRemoveOptions{
|
||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
|
||||
Force: force,
|
||||
PruneChildren: pruneChildren,
|
||||
}); err != nil {
|
||||
|
||||
@@ -6,66 +6,64 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/client"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
|
||||
func TestImageExistsLocally(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
// to help make this test reliable and not flaky, we need to have
|
||||
// an image that will exist, and onew that won't exist
|
||||
|
||||
// Test if image exists with specific tag
|
||||
invalidImageTag, 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.False(t, invalidImageTag)
|
||||
|
||||
// Test if image exists with specific architecture (image platform)
|
||||
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.False(t, invalidImagePlatform)
|
||||
|
||||
// pull an image
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
cli.NegotiateAPIVersion(context.Background())
|
||||
|
||||
// Chose alpine latest because it's so small
|
||||
// maybe we should build an image instead so that tests aren't reliable on dockerhub
|
||||
readerDefault, err := cli.ImagePull(ctx, "node:24-bookworm-slim", types.ImagePullOptions{
|
||||
Platform: "linux/amd64",
|
||||
})
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
defer readerDefault.Close()
|
||||
_, err = io.ReadAll(readerDefault)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:24-bookworm-slim", "linux/amd64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, imageDefaultArchExists)
|
||||
|
||||
// Validate if another architecture platform can be pulled
|
||||
readerArm64, err := cli.ImagePull(ctx, "node:24-bookworm-slim", types.ImagePullOptions{
|
||||
Platform: "linux/arm64",
|
||||
})
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
defer readerArm64.Close()
|
||||
_, err = io.ReadAll(readerArm64)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
imageArm64Exists, err := ImageExistsLocally(ctx, "node:24-bookworm-slim", "linux/arm64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, imageArm64Exists)
|
||||
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
||||
// 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()
|
||||
tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/"))
|
||||
cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-")
|
||||
cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n")
|
||||
// Force BuildKit: it records the requested architecture in the image config for a
|
||||
// FROM-scratch build, whereas the classic builder ignores --platform and tags it with the
|
||||
// host arch, which would break the per-platform existence assertions below.
|
||||
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
|
||||
return tag
|
||||
}
|
||||
|
||||
func TestImageExistsLocally(t *testing.T) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// a non-existent image is reported absent
|
||||
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.False(t, missing)
|
||||
|
||||
// Build tiny images for two architectures locally so per-platform existence can be checked
|
||||
// offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network).
|
||||
amd64Ref := buildScratchImage(t, "linux/amd64")
|
||||
arm64Ref := buildScratchImage(t, "linux/arm64")
|
||||
|
||||
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, amd64Exists)
|
||||
|
||||
// a non-host architecture image is detected for its own architecture
|
||||
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, arm64Exists)
|
||||
|
||||
// a present image is reported absent for a different platform
|
||||
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.False(t, wrongPlatform)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
||||
|
||||
if msg.ErrorDetail.Message != "" {
|
||||
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
||||
return errors.New(msg.Error)
|
||||
return errors.New(msg.ErrorDetail.Message)
|
||||
}
|
||||
|
||||
if msg.Status != "" {
|
||||
|
||||
@@ -8,13 +8,70 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
const (
|
||||
networkCreateAttempts = 3
|
||||
networkCreateRetryDelay = time.Second
|
||||
|
||||
// marks the networks a runner creates for its jobs, so it can tell its own leftovers from
|
||||
// those of another runner sharing the daemon
|
||||
runnerUUIDLabel = "com.gitea.runner.uuid"
|
||||
)
|
||||
|
||||
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
|
||||
// not get to them: the runner died with the job, the teardown timed out, or the network still
|
||||
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
||||
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
||||
// while this runs cannot lose the network it has created but not yet attached a container to.
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore)
|
||||
}
|
||||
|
||||
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error {
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
||||
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
||||
continue
|
||||
}
|
||||
// the emptiness check, not the label, is what keeps a live job of another process
|
||||
// sharing this registration safe
|
||||
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) {
|
||||
continue
|
||||
}
|
||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||
continue
|
||||
}
|
||||
common.Logger(ctx).Infof("removed docker network %s left behind by an earlier job", n.Name)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
@@ -23,29 +80,58 @@ func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
defer cli.Close()
|
||||
|
||||
// Only create the network if it doesn't exist
|
||||
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
for _, network := range networks {
|
||||
if network.Name == name {
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err = cli.NetworkCreate(ctx, name, types.NetworkCreate{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
for i := range networkCreateAttempts {
|
||||
if i > 0 {
|
||||
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||
}
|
||||
}
|
||||
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
EnableIPv4: opts.EnableIPv4,
|
||||
EnableIPv6: opts.EnableIPv6,
|
||||
Labels: runnerLabels(opts.RunnerUUID),
|
||||
}); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAddressPoolExhausted(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runnerLabels(runnerUUID string) map[string]string {
|
||||
if runnerUUID == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{runnerUUIDLabel: runnerUUID}
|
||||
}
|
||||
|
||||
// The daemon reports this as a plain invalid-parameter error, the same kind it uses for every
|
||||
// malformed request, so the message is the only discriminator.
|
||||
func isAddressPoolExhausted(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "all predefined address pools have been fully subnetted") ||
|
||||
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
||||
}
|
||||
|
||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
@@ -56,31 +142,33 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// Make shure that all network of the specified name are removed
|
||||
// Make sure that all network of the specified name are removed
|
||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
||||
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
for _, network := range networks {
|
||||
if network.Name == name {
|
||||
result, err := cli.NetworkInspect(ctx, network.ID, types.NetworkInspectOptions{})
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(result.Containers) == 0 {
|
||||
if err = cli.NetworkRemove(ctx, network.ID); err != nil {
|
||||
common.Logger(ctx).Debugf("%v", err)
|
||||
}
|
||||
} else {
|
||||
common.Logger(ctx).Debugf("Refusing to remove network %v because it still has active endpoints", name)
|
||||
// it holds a subnet out of the daemon's pool until something reclaims it
|
||||
if len(result.Network.Containers) != 0 {
|
||||
common.Logger(ctx).Warnf("Refusing to remove network %s because it still has active endpoints, the idle cleanup reclaims it once they are gone", name)
|
||||
continue
|
||||
}
|
||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
|
||||
50
act/container/docker_network_test.go
Normal file
50
act/container/docker_network_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||
}
|
||||
|
||||
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
|
||||
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
||||
// test on its own, since testify has no expectation to match it against.
|
||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||
client := &mockDockerClient{}
|
||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
||||
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||
{Network: network.Network{ID: "orphan"}},
|
||||
{Network: network.Network{ID: "busy"}},
|
||||
{Network: network.Network{ID: "starting"}},
|
||||
}}, nil)
|
||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil)
|
||||
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||
|
||||
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
39
act/container/docker_platform.go
Normal file
39
act/container/docker_platform.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2025 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// parsePlatform parses an "os/arch[/variant]" string into a Platform. An empty input
|
||||
// returns (nil, nil), meaning "no platform constraint". A non-empty but malformed
|
||||
// string is rejected explicitly so it cannot silently fall through to the daemon's
|
||||
// default architecture.
|
||||
func parsePlatform(platform string) (*specs.Platform, error) {
|
||||
if platform == "" {
|
||||
return nil, nil //nolint:nilnil // no platform constraint requested
|
||||
}
|
||||
|
||||
parts := strings.Split(platform, "/")
|
||||
if len(parts) < 2 || len(parts) > 3 || parts[0] == "" || parts[1] == "" || (len(parts) == 3 && parts[2] == "") {
|
||||
return nil, fmt.Errorf("invalid platform %q: expected os/arch[/variant]", platform)
|
||||
}
|
||||
|
||||
spec := &specs.Platform{
|
||||
OS: strings.ToLower(parts[0]),
|
||||
Architecture: strings.ToLower(parts[1]),
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
spec.Variant = strings.ToLower(parts[2])
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
63
act/container/docker_platform_test.go
Normal file
63
act/container/docker_platform_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsePlatform(t *testing.T) {
|
||||
t.Run("empty input returns nil platform without error", func(t *testing.T) {
|
||||
got, err := parsePlatform("")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
|
||||
t.Run("os/arch", func(t *testing.T) {
|
||||
got, err := parsePlatform("linux/amd64")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "linux", got.OS)
|
||||
assert.Equal(t, "amd64", got.Architecture)
|
||||
assert.Empty(t, got.Variant)
|
||||
})
|
||||
|
||||
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{
|
||||
"amd64",
|
||||
"linux",
|
||||
"linux/",
|
||||
"/amd64",
|
||||
"/",
|
||||
"//",
|
||||
"linux/arm/",
|
||||
"linux/arm/v7/extra",
|
||||
} {
|
||||
t.Run("rejects "+bad, func(t *testing.T) {
|
||||
got, err := parsePlatform(bad)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,16 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/moby/moby/api/pkg/authconfig"
|
||||
"github.com/moby/moby/api/types/registry"
|
||||
"github.com/moby/moby/client"
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// NewDockerPullExecutor function to create a run executor for the container
|
||||
@@ -30,23 +30,19 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return nil
|
||||
}
|
||||
|
||||
pull := input.ForcePull
|
||||
if !pull {
|
||||
// skip the pull when the image is already here: either none was forced, or a digest
|
||||
// pins the content so a forced pull could only fetch the same bytes again
|
||||
if !input.ForcePull || isPinnedImage(input.Image) {
|
||||
imageExists, err := ImageExistsLocally(ctx, input.Image, input.Platform)
|
||||
logger.Debugf("Image exists? %v", imageExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine if image already exists for image '%s' (%s): %w", input.Image, input.Platform, err)
|
||||
}
|
||||
|
||||
if !imageExists {
|
||||
pull = true
|
||||
if imageExists {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if !pull {
|
||||
return nil
|
||||
}
|
||||
|
||||
imageRef := cleanImage(ctx, input.Image)
|
||||
logger.Debugf("pulling image '%v' (%s)", imageRef, input.Platform)
|
||||
|
||||
@@ -61,43 +57,58 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return err
|
||||
}
|
||||
|
||||
reader, err := cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||
|
||||
_ = logDockerResponse(logger, reader, err != nil)
|
||||
if err != nil {
|
||||
if imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||
imagePullOptions.RegistryAuth = ""
|
||||
reader, err = cli.ImagePull(ctx, imageRef, imagePullOptions)
|
||||
|
||||
_ = logDockerResponse(logger, reader, err != nil)
|
||||
// the daemon reports a failure that happens after the first progress line in the
|
||||
// stream rather than on the call itself, so both have to be checked
|
||||
pullOnce := func(opts client.ImagePullOptions) error {
|
||||
reader, err := cli.ImagePull(ctx, imageRef, opts)
|
||||
streamErr := logDockerResponse(logger, reader, err != nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
return streamErr
|
||||
}
|
||||
return nil
|
||||
|
||||
err = pullOnce(imagePullOptions)
|
||||
if err != nil && imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||
imagePullOptions.RegistryAuth = ""
|
||||
err = pullOnce(imagePullOptions)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// a registry that is down should not fail a job whose image is already here
|
||||
if exists, existsErr := ImageExistsLocally(ctx, input.Image, input.Platform); existsErr == nil && exists {
|
||||
logger.Warnf("could not update image '%s' (%s), continuing with the local copy: %v", imageRef, input.Platform, err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||
}
|
||||
}
|
||||
|
||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (types.ImagePullOptions, error) {
|
||||
imagePullOptions := types.ImagePullOptions{
|
||||
Platform: input.Platform,
|
||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
||||
imagePullOptions := client.ImagePullOptions{}
|
||||
platform, err := parsePlatform(input.Platform)
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
if platform != nil {
|
||||
imagePullOptions.Platforms = []specs.Platform{*platform}
|
||||
}
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
if input.Username != "" && input.Password != "" {
|
||||
logger.Debugf("using authentication for docker pull")
|
||||
|
||||
authConfig := registry.AuthConfig{
|
||||
encodedAuth, err := authconfig.Encode(registry.AuthConfig{
|
||||
Username: input.Username,
|
||||
Password: input.Password,
|
||||
}
|
||||
|
||||
encodedJSON, err := json.Marshal(authConfig)
|
||||
})
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
|
||||
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||
imagePullOptions.RegistryAuth = encodedAuth
|
||||
} else {
|
||||
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
|
||||
if err != nil {
|
||||
@@ -108,19 +119,26 @@ func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput)
|
||||
}
|
||||
logger.Info("using DockerAuthConfig authentication for docker pull")
|
||||
|
||||
encodedJSON, err := json.Marshal(authConfig)
|
||||
imagePullOptions.RegistryAuth, err = authconfig.Encode(authConfig)
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
|
||||
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||
}
|
||||
|
||||
return imagePullOptions, nil
|
||||
}
|
||||
|
||||
func cleanImage(ctx context.Context, image string) string {
|
||||
func isPinnedImage(image string) bool {
|
||||
ref, err := reference.ParseAnyReference(image)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, pinned := ref.(reference.Canonical)
|
||||
return pinned
|
||||
}
|
||||
|
||||
func cleanImage(ctx context.Context, imageName string) string {
|
||||
ref, err := reference.ParseAnyReference(imageName)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Error(err)
|
||||
return ""
|
||||
|
||||
@@ -6,11 +6,15 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -40,6 +44,9 @@ func TestCleanImage(t *testing.T) {
|
||||
func TestGetImagePullOptions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
orig := config.Dir()
|
||||
t.Cleanup(func() { config.SetDir(orig) })
|
||||
|
||||
config.SetDir("/non-existent/docker")
|
||||
|
||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||
@@ -62,3 +69,21 @@ func TestGetImagePullOptions(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
// A digest-pinned image is immutable, so its local copy is always current.
|
||||
func TestIsPinnedImage(t *testing.T) {
|
||||
assert.True(t, isPinnedImage("alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"))
|
||||
assert.False(t, isPinnedImage("alpine:latest"))
|
||||
}
|
||||
|
||||
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
||||
// whichever of the two shapes the daemon used.
|
||||
func TestLogDockerResponseError(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
streamErr := func(line string) error {
|
||||
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
||||
}
|
||||
require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded")
|
||||
require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF")
|
||||
require.NoError(t, streamErr(`{"status":"Downloading"}`))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,21 +5,27 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -27,9 +33,10 @@ import (
|
||||
)
|
||||
|
||||
func TestDocker(t *testing.T) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
client, err := GetDockerClient(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||
@@ -67,33 +74,78 @@ func TestDocker(t *testing.T) {
|
||||
}
|
||||
|
||||
type mockDockerClient struct {
|
||||
client.APIClient
|
||||
mobyclient.APIClient
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerExecCreate(ctx context.Context, id string, opts types.ExecConfig) (types.IDResponse, error) {
|
||||
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(types.IDResponse), args.Error(1)
|
||||
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerExecAttach(ctx context.Context, id string, opts types.ExecStartCheck) (types.HijackedResponse, error) {
|
||||
func (m *mockDockerClient) ExecAttach(ctx context.Context, id string, opts mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(types.HijackedResponse), args.Error(1)
|
||||
return args.Get(0).(mobyclient.ExecAttachResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerExecInspect(ctx context.Context, execID string) (types.ContainerExecInspect, error) {
|
||||
args := m.Called(ctx, execID)
|
||||
return args.Get(0).(types.ContainerExecInspect), args.Error(1)
|
||||
func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts mobyclient.ExecInspectOptions) (mobyclient.ExecInspectResult, error) {
|
||||
args := m.Called(ctx, execID, opts)
|
||||
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error) {
|
||||
args := m.Called(ctx, containerID, condition)
|
||||
return args.Get(0).(<-chan container.WaitResponse), args.Get(1).(<-chan error)
|
||||
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, containerID string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id, path string, content io.Reader, options types.CopyToContainerOptions) error {
|
||||
args := m.Called(ctx, id, path, content, options)
|
||||
return args.Error(0)
|
||||
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerWaitResult)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, options mobyclient.CopyToContainerOptions) (mobyclient.CopyToContainerResult, error) {
|
||||
args := m.Called(ctx, id, options)
|
||||
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerKill(ctx context.Context, id string, opts mobyclient.ContainerKillOptions) (mobyclient.ContainerKillResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerKillResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.NetworkListOptions) (mobyclient.NetworkListResult, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mobyclient.NetworkRemoveOptions) (mobyclient.NetworkRemoveResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
|
||||
}
|
||||
|
||||
type endlessReader struct {
|
||||
@@ -125,10 +177,12 @@ func TestDockerExecAbort(t *testing.T) {
|
||||
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
|
||||
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
|
||||
Conn: conn,
|
||||
Reader: bufio.NewReader(endlessReader{}),
|
||||
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||
HijackedResponse: mobyclient.HijackedResponse{
|
||||
Conn: conn,
|
||||
Reader: bufio.NewReader(endlessReader{}),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
cr := &containerReference{
|
||||
@@ -162,12 +216,14 @@ func TestDockerExecFailure(t *testing.T) {
|
||||
conn := &mockConn{}
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
|
||||
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
|
||||
Conn: conn,
|
||||
Reader: bufio.NewReader(strings.NewReader("output")),
|
||||
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||
HijackedResponse: mobyclient.HijackedResponse{
|
||||
Conn: conn,
|
||||
Reader: bufio.NewReader(strings.NewReader("output")),
|
||||
},
|
||||
}, nil)
|
||||
client.On("ContainerExecInspect", ctx, "id").Return(types.ContainerExecInspect{
|
||||
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
|
||||
ExitCode: 1,
|
||||
}, nil)
|
||||
|
||||
@@ -189,6 +245,71 @@ func TestDockerExecFailure(t *testing.T) {
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// stdcopyFrame wraps payload in a single Docker multiplexed-stream frame, the
|
||||
// format StdCopy expects: an 8-byte header (stream type + 4-byte big-endian
|
||||
// length) followed by the payload.
|
||||
func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
||||
b := make([]byte, 8+len(payload))
|
||||
b[0] = byte(stream)
|
||||
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
|
||||
copy(b[8:], payload)
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
|
||||
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||
|
||||
var lines []string
|
||||
logWriter := common.NewLineWriter(func(s string) bool {
|
||||
lines = append(lines, s)
|
||||
return true
|
||||
})
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
||||
Return(mobyclient.ContainerAttachResult{
|
||||
HijackedResponse: mobyclient.HijackedResponse{
|
||||
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))
|
||||
require.NoError(t, cr.wait()(ctx))
|
||||
|
||||
// wait() must have blocked until the goroutine drained AND flushed; the
|
||||
// trailing, non-newline-terminated line must therefore be present. Reading
|
||||
// lines here is race-free because wait() synchronizes on attachDone, which
|
||||
// the goroutine closes after the final append.
|
||||
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerWaitFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -197,8 +318,11 @@ func TestDockerWaitFailure(t *testing.T) {
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerWait", ctx, "123", container.WaitConditionNotRunning).
|
||||
Return((<-chan container.WaitResponse)(statusCh), (<-chan error)(errCh))
|
||||
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",
|
||||
@@ -217,14 +341,38 @@ func TestDockerWaitFailure(t *testing.T) {
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// stubStatPath answers path resolution: the given paths exist, mapped to their target
|
||||
// when they are a symlink, everything else does not exist.
|
||||
func stubStatPath(client *mockDockerClient, existing map[string]string) {
|
||||
for containerPath, target := range existing {
|
||||
client.On("ContainerStatPath", mock.Anything, "123", mobyclient.ContainerStatPathOptions{Path: containerPath}).
|
||||
Return(mobyclient.ContainerStatPathResult{Stat: container.PathStat{LinkTarget: target}}, nil).Maybe()
|
||||
}
|
||||
client.On("ContainerStatPath", mock.Anything, "123", mock.Anything).
|
||||
Return(mobyclient.ContainerStatPathResult{}, cerrdefs.ErrNotFound).Maybe()
|
||||
}
|
||||
|
||||
// The mkdir tarball is extracted at the deepest existing ancestor, with entries relative
|
||||
// to it that never traverse the "/var/run" symlink, see moby/moby#53258.
|
||||
func TestDockerCopyTarStream(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
conn := &mockConn{}
|
||||
|
||||
var mkdirNames []string
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
||||
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
||||
stubStatPath(client, map[string]string{"/var": "", "/var/run": "/run", "/run": ""})
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
if opts.DestinationPath != "/run" || opts.Content == nil {
|
||||
return false
|
||||
}
|
||||
tr := tar.NewReader(opts.Content)
|
||||
for hdr, err := tr.Next(); err == nil; hdr, err = tr.Next() {
|
||||
mkdirNames = append(mkdirNames, hdr.Name)
|
||||
}
|
||||
return true
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/run/act" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
@@ -233,60 +381,231 @@ func TestDockerCopyTarStream(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||
assert.Equal(t, []string{"act"}, mkdirNames)
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
conn := &mockConn{}
|
||||
|
||||
func TestDockerCopyTarStreamErrors(t *testing.T) {
|
||||
merr := errors.New("Failure")
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
mkdirErr error
|
||||
copyErr error
|
||||
}{
|
||||
{"mkdir", merr, nil},
|
||||
{"copy content", nil, merr},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
||||
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
client := &mockDockerClient{}
|
||||
stubStatPath(client, map[string]string{"/var": "", "/var/run": ""})
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, testCase.mkdirErr)
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, testCase.copyErr).Maybe()
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
require.ErrorIs(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}), merr)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||
// be logged as one.
|
||||
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
wantWait bool
|
||||
wantFailure bool
|
||||
}{
|
||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
||||
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
||||
{name: "removed cleanly", err: nil},
|
||||
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||
if tc.wantWait {
|
||||
removed := make(chan container.WaitResponse, 1)
|
||||
removed <- container.WaitResponse{}
|
||||
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||
Return(mobyclient.ContainerWaitResult{Result: removed})
|
||||
}
|
||||
cr := &containerReference{id: "abc", cli: client}
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
// a failure keeps the id, so a later Remove() can retry it
|
||||
if tc.wantFailure {
|
||||
assert.Equal(t, "abc", cr.id)
|
||||
assert.Len(t, hook.AllEntries(), 1)
|
||||
} else {
|
||||
assert.Empty(t, cr.id)
|
||||
assert.Empty(t, hook.AllEntries())
|
||||
}
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A container whose id was never learned, because find() could not reach the daemon or
|
||||
// create() lost its reply, must still be removed rather than leaking with its network. It
|
||||
// was never started here, so it is not worth a kill of its own.
|
||||
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||
// daemon with a torn-down container.
|
||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
||||
boom := errors.New("daemon unreachable")
|
||||
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
||||
client := &mockDockerClient{}
|
||||
return &containerReference{id: id, cli: client, input: &NewContainerInput{Name: "job-1"}}, client
|
||||
}
|
||||
listOpts := mobyclient.ContainerListOptions{All: true}
|
||||
inspectOpts := mobyclient.ContainerInspectOptions{}
|
||||
|
||||
t.Run("stale id cleared, name lookup empty", func(t *testing.T) {
|
||||
cr, client := newCR("stale")
|
||||
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, nil)
|
||||
require.NoError(t, cr.find()(ctx))
|
||||
assert.Empty(t, cr.id)
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("stale id cleared, name lookup repopulates", func(t *testing.T) {
|
||||
cr, client := newCR("stale")
|
||||
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
||||
{ID: "other", Names: []string{"/somebody-else"}},
|
||||
{ID: "fresh", Names: []string{"/job-1"}},
|
||||
}}, nil)
|
||||
require.NoError(t, cr.find()(ctx))
|
||||
assert.Equal(t, "fresh", cr.id)
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
|
||||
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
|
||||
// error when no live cr.id is known.
|
||||
func TestRejectsMissingContainer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
check := func(op string, err error) {
|
||||
t.Helper()
|
||||
require.Error(t, err, op)
|
||||
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
||||
}
|
||||
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
||||
check("CopyTarStream", cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||
check("GetContainerArchive", err)
|
||||
}
|
||||
|
||||
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
|
||||
// and the Copy completes against the fresh id.
|
||||
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
||||
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
|
||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
||||
{ID: "fresh", Names: []string{"/job-1"}},
|
||||
}}, nil)
|
||||
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act"
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
|
||||
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||
assert.Equal(t, "fresh", cr.id)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestDockerCopyToSymlinkPath is a regression test for gitea/runner#981. Most base images
|
||||
// symlink /var/run to /run, so copying into /var/run/act traverses that symlink. The broken
|
||||
// docker 29.5.1 daemon fails the extraction with "mkdirat var/run: file exists" (fixed in
|
||||
// 29.5.2). Running against the daemon shipped in the dind image, this catches a bad bump.
|
||||
func TestDockerCopyToSymlinkPath(t *testing.T) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
|
||||
conn := &mockConn{}
|
||||
rc := NewContainer(&NewContainerInput{
|
||||
Image: "alpine:latest",
|
||||
Entrypoint: []string{"sleep", "30"},
|
||||
Name: "act-test-symlink-" + time.Now().Format("20060102150405.000000"),
|
||||
AutoRemove: true,
|
||||
})
|
||||
require.NoError(t, rc.Pull(false)(ctx))
|
||||
require.NoError(t, rc.Create(nil, nil)(ctx))
|
||||
require.NoError(t, rc.Start(false)(ctx))
|
||||
t.Cleanup(func() {
|
||||
_ = rc.Remove()(ctx)
|
||||
_ = rc.Close()(ctx)
|
||||
})
|
||||
|
||||
merr := errors.New("Failure")
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
||||
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
client.AssertExpectations(t)
|
||||
// CopyTarStream resolves the var/run symlink and creates act below its target, the
|
||||
// exact step that fails on a broken daemon.
|
||||
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Type assert containerReference implements ExecutionsEnvironment
|
||||
@@ -364,3 +683,163 @@ func TestCheckVolumes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
|
||||
dangerous := func() *container.HostConfig {
|
||||
return &container.HostConfig{
|
||||
PidMode: "host",
|
||||
IpcMode: "host",
|
||||
UTSMode: "host",
|
||||
CgroupnsMode: "host",
|
||||
UsernsMode: "host",
|
||||
CapAdd: []string{"ALL"},
|
||||
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
||||
VolumesFrom: []string{"other"},
|
||||
Runtime: "runc",
|
||||
Resources: container.Resources{
|
||||
CgroupParent: "/custom",
|
||||
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
||||
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||
},
|
||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||
}
|
||||
}
|
||||
|
||||
hostConfig := dangerous()
|
||||
sanitizeOptionsHostConfig(logger, hostConfig)
|
||||
|
||||
assert.Empty(t, string(hostConfig.PidMode))
|
||||
assert.Empty(t, string(hostConfig.IpcMode))
|
||||
assert.Empty(t, string(hostConfig.UTSMode))
|
||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||
assert.Empty(t, string(hostConfig.UsernsMode))
|
||||
assert.Empty(t, hostConfig.CapAdd)
|
||||
assert.Empty(t, hostConfig.SecurityOpt)
|
||||
assert.Empty(t, hostConfig.Devices)
|
||||
assert.Empty(t, hostConfig.DeviceCgroupRules)
|
||||
assert.Empty(t, hostConfig.VolumesFrom)
|
||||
assert.Empty(t, hostConfig.Runtime)
|
||||
assert.Empty(t, hostConfig.CgroupParent)
|
||||
assert.Empty(t, hostConfig.Sysctls)
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
||||
// OS-independent options only: --device parsing requires a linux/windows
|
||||
// server OS, which is not guaranteed for the test host.
|
||||
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||
"--security-opt apparmor=unconfined --volumes-from other " +
|
||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
||||
|
||||
t.Run("unprivileged strips host-escape options", func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
Options: dangerousOptions,
|
||||
NetworkMode: "bridge",
|
||||
UsernsMode: "private",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Privileged: false,
|
||||
UsernsMode: container.UsernsMode("private"),
|
||||
NetworkMode: container.NetworkMode("bridge"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, hostConfig.Privileged)
|
||||
assert.Empty(t, string(hostConfig.PidMode))
|
||||
assert.Empty(t, string(hostConfig.IpcMode))
|
||||
assert.Empty(t, string(hostConfig.UTSMode))
|
||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||
// UsernsMode must keep the runner-controlled value, not the one from options.
|
||||
assert.Equal(t, "private", string(hostConfig.UsernsMode))
|
||||
assert.Empty(t, hostConfig.CapAdd)
|
||||
assert.Empty(t, hostConfig.SecurityOpt)
|
||||
assert.Empty(t, hostConfig.VolumesFrom)
|
||||
assert.Empty(t, hostConfig.Runtime)
|
||||
assert.Empty(t, hostConfig.CgroupParent)
|
||||
assert.Empty(t, hostConfig.Sysctls)
|
||||
})
|
||||
|
||||
t.Run("privileged preserves options", func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
Options: "--pid=host --cap-add=ALL --security-opt seccomp=unconfined",
|
||||
NetworkMode: "bridge",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Privileged: true,
|
||||
NetworkMode: container.NetworkMode("bridge"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "host", string(hostConfig.PidMode))
|
||||
assert.Equal(t, []string{"ALL"}, hostConfig.CapAdd)
|
||||
assert.Equal(t, []string{"seccomp=unconfined"}, hostConfig.SecurityOpt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
|
||||
base := t.TempDir()
|
||||
allowed := filepath.Join(base, "allowed")
|
||||
denied := filepath.Join(base, "denied")
|
||||
require.NoError(t, os.MkdirAll(allowed, 0o700))
|
||||
require.NoError(t, os.MkdirAll(denied, 0o700))
|
||||
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
ValidVolumes: []string{filepath.Join(allowed, "**")},
|
||||
},
|
||||
}
|
||||
|
||||
escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied"
|
||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{escapingPath + ":/mnt"},
|
||||
})
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
|
||||
linkPath := filepath.Join(allowed, "link")
|
||||
if err := os.Symlink(denied, linkPath); err != nil {
|
||||
t.Skipf("cannot create symlink: %v", err)
|
||||
}
|
||||
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{linkPath + ":/mnt"},
|
||||
})
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
|
||||
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"},
|
||||
})
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
NetworkMode: "bridge",
|
||||
Options: "--volume /host/tools:/opt/hostedtoolcache",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConf, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
|
||||
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
||||
assert.Empty(t, hostConf.Mounts)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,19 @@ func init() {
|
||||
|
||||
var originalCommonSocketLocations = CommonSocketLocations
|
||||
|
||||
func isolateSocketEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
|
||||
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
|
||||
t.Setenv("DOCKER_HOST", host)
|
||||
} else {
|
||||
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
||||
// Arrange
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
isolateSocketEnv(t)
|
||||
dockerHost := "unix:///my/docker/host.sock"
|
||||
socketURI := "/path/to/my.socket"
|
||||
t.Setenv("DOCKER_HOST", dockerHost)
|
||||
@@ -48,9 +58,9 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
socketURI := "/path/to/my.socket"
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
defaultSocket, defaultSocketFound := socketLocation()
|
||||
|
||||
// Act
|
||||
@@ -65,7 +75,7 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||
// Arrange
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
isolateSocketEnv(t)
|
||||
dockerHost := "unix:///my/docker/host.sock"
|
||||
t.Setenv("DOCKER_HOST", dockerHost)
|
||||
|
||||
@@ -79,7 +89,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||
// Arrange
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
isolateSocketEnv(t)
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
defaultSocket, found := socketLocation()
|
||||
|
||||
@@ -97,6 +107,7 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
||||
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
||||
mySocket := mySocketFile.Name()
|
||||
unixSocket := "unix://" + mySocket
|
||||
@@ -119,6 +130,7 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
mySocket := "/my/socket/path.sock"
|
||||
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
||||
@@ -136,6 +148,7 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
socketURI := "unix:///path/to/my.socket"
|
||||
CommonSocketLocations = []string{"/unusual", "/location"}
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
|
||||
@@ -9,10 +9,11 @@ package container
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/moby/moby/api/types/system"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -51,8 +52,8 @@ func RunnerArch(ctx context.Context) string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func GetHostInfo(ctx context.Context) (info types.Info, err error) {
|
||||
return types.Info{}, nil
|
||||
func GetHostInfo(ctx context.Context) (info system.Info, err error) {
|
||||
return system.Info{}, nil
|
||||
}
|
||||
|
||||
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
||||
@@ -61,7 +62,7 @@ func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
@@ -72,3 +73,7 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ import (
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||
@@ -23,12 +22,12 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
list, err := cli.VolumeList(ctx, volume.ListOptions{Filters: filters.NewArgs()})
|
||||
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, vol := range list.Volumes {
|
||||
for _, vol := range list.Items {
|
||||
if vol.Name == volumeName {
|
||||
return removeExecutor(volumeName, force)(ctx)
|
||||
}
|
||||
@@ -54,6 +53,7 @@ func removeExecutor(volume string, force bool) common.Executor {
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
return cli.VolumeRemove(ctx, volume, force)
|
||||
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
27
act/container/helpers_test.go
Normal file
27
act/container/helpers_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// requireDocker skips the test unless a reachable docker daemon is available.
|
||||
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||
func requireDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,15 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
"gitea.com/gitea/runner/act/lookpath"
|
||||
"gitea.com/gitea/runner/internal/pkg/process"
|
||||
|
||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
@@ -36,15 +37,32 @@ type HostEnvironment struct {
|
||||
TmpDir string
|
||||
ToolCache string
|
||||
Workdir string
|
||||
// BindWorkdir is true when the app runner mounts the workspace on the host and
|
||||
// deletes the task directory after the job; host teardown must not remove Workdir.
|
||||
BindWorkdir bool
|
||||
ActPath string
|
||||
CleanUp func()
|
||||
StdOut io.Writer
|
||||
// CleanWorkdir means teardown owns Workdir and may delete it. Leave false
|
||||
// when Workdir points at a caller-owned checkout (e.g. `act` local mode).
|
||||
CleanWorkdir bool
|
||||
ActPath string
|
||||
CleanUp func()
|
||||
StdOut io.Writer
|
||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||
|
||||
mu sync.Mutex
|
||||
runningPIDs map[int]struct{}
|
||||
// procGroup owns every process the job's steps start. Atomic: Remove may read
|
||||
// it while a step is still starting.
|
||||
procGroupOnce sync.Once
|
||||
procGroup atomic.Pointer[process.Group]
|
||||
}
|
||||
|
||||
// processGroup returns the job-scoped process group, creating it on first use.
|
||||
// Returns nil if the job object could not be created; Group is nil-safe.
|
||||
func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group {
|
||||
e.procGroupOnce.Do(func() {
|
||||
group, err := process.NewGroup()
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("could not create the job's process group; processes a step leaves behind can only be reclaimed by the workspace scan: %v", err)
|
||||
return
|
||||
}
|
||||
e.procGroup.Store(group)
|
||||
})
|
||||
return e.procGroup.Load()
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||
@@ -200,12 +218,12 @@ func (e *HostEnvironment) Start(_ bool) common.Executor {
|
||||
|
||||
type ptyWriter struct {
|
||||
Out io.Writer
|
||||
AutoStop bool
|
||||
AutoStop atomic.Bool
|
||||
dirtyLine bool
|
||||
}
|
||||
|
||||
func (w *ptyWriter) Write(buf []byte) (int, error) {
|
||||
if w.AutoStop && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
||||
if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
||||
n, err := w.Out.Write(buf[:len(buf)-1])
|
||||
if err != nil {
|
||||
return n, err
|
||||
@@ -264,7 +282,7 @@ func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||
cmd.Stdin = tty
|
||||
cmd.Stdout = tty
|
||||
cmd.Stderr = tty
|
||||
cmd.SysProcAttr = getSysProcAttr(cmdline, true)
|
||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, true)
|
||||
return ppty, tty, nil
|
||||
}
|
||||
|
||||
@@ -312,6 +330,10 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
} else {
|
||||
wd = e.Path
|
||||
}
|
||||
// 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.
|
||||
defer common.FlushWriter(e.StdOut)
|
||||
|
||||
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -324,7 +346,12 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
cmd.Env = envList
|
||||
cmd.Stderr = e.StdOut
|
||||
cmd.Dir = wd
|
||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
||||
|
||||
// Kills the step's whole tree on cancellation and bounds the post-exit I/O
|
||||
// wait, so an orphan holding cmd's stdout pipe cannot hang cmd.Wait().
|
||||
treeKill := process.NewTreeKill(cmd)
|
||||
|
||||
var ppty *os.File
|
||||
var tty *os.File
|
||||
defer func() {
|
||||
@@ -335,40 +362,34 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
tty.Close()
|
||||
}
|
||||
}()
|
||||
if true /* allocate Terminal */ {
|
||||
if e.AllocatePTY {
|
||||
var err error
|
||||
ppty, tty, err = setupPty(cmd, cmdline)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
|
||||
}
|
||||
}
|
||||
writer := &ptyWriter{Out: e.StdOut}
|
||||
logctx, finishLog := context.WithCancel(context.Background())
|
||||
var writer *ptyWriter
|
||||
var logctx context.Context
|
||||
if ppty != nil {
|
||||
writer = &ptyWriter{Out: e.StdOut}
|
||||
var finishLog context.CancelFunc
|
||||
logctx, finishLog = context.WithCancel(context.Background())
|
||||
go copyPtyOutput(writer, ppty, finishLog)
|
||||
} else {
|
||||
finishLog()
|
||||
}
|
||||
if ppty != nil {
|
||||
go writeKeepAlive(ppty)
|
||||
}
|
||||
// Split Start/Wait so the PID can be registered before the process can exit;
|
||||
// cmd.Run() would block until exit, by which time the PID may have been reused.
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
e.mu.Lock()
|
||||
if e.runningPIDs == nil {
|
||||
e.runningPIDs = map[int]struct{}{}
|
||||
}
|
||||
e.runningPIDs[cmd.Process.Pid] = struct{}{}
|
||||
e.mu.Unlock()
|
||||
defer func(pid int) {
|
||||
e.mu.Lock()
|
||||
delete(e.runningPIDs, pid)
|
||||
e.mu.Unlock()
|
||||
}(cmd.Process.Pid)
|
||||
// Assign before the step's Killer so the step's job nests inside the group's;
|
||||
// cancellation still scopes to this step's tree.
|
||||
if err := e.processGroup(ctx).Assign(cmd.Process); err != nil {
|
||||
common.Logger(ctx).Warnf("could not assign the step's process to the job's process group; a process it leaves behind may outlive the job: %v", err)
|
||||
}
|
||||
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
|
||||
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||
} else {
|
||||
defer k.Close()
|
||||
}
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
@@ -379,14 +400,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
return err
|
||||
}
|
||||
if tty != nil {
|
||||
writer.AutoStop = true
|
||||
writer.AutoStop.Store(true)
|
||||
if _, err := tty.WriteString("\x04"); err != nil {
|
||||
common.Logger(ctx).Debug("Failed to write EOT")
|
||||
}
|
||||
}
|
||||
<-logctx.Done()
|
||||
|
||||
if ppty != nil {
|
||||
<-logctx.Done()
|
||||
ppty.Close()
|
||||
ppty = nil
|
||||
}
|
||||
@@ -415,6 +433,23 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
|
||||
return parseEnvFile(e, srcPath, env)
|
||||
}
|
||||
|
||||
// removeAll is a var so tests can substitute a blocking stub.
|
||||
var removeAll = os.RemoveAll
|
||||
|
||||
// removeAllWithContext returns once the delete finishes or ctx is cancelled. On
|
||||
// cancellation the goroutine leaks: a delete inside a syscall cannot be
|
||||
// interrupted (see runWithTimeout).
|
||||
func removeAllWithContext(ctx context.Context, path string) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- removeAll(path) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func removePathWithRetry(ctx context.Context, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
@@ -434,64 +469,165 @@ func removePathWithRetry(ctx context.Context, path string) error {
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
lastErr = os.RemoveAll(path)
|
||||
lastErr = removeAllWithContext(ctx, path)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(lastErr, context.DeadlineExceeded) {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// buildWindowsWorkspaceKillScript builds a PowerShell command that taskkills
|
||||
// every process tree whose ExecutablePath or CommandLine references one of the
|
||||
// given workspace dirs, releasing file handles for cleanup. Win32_Process
|
||||
// exposes both fields (Get-Process doesn't, wmic is deprecated); matching is on
|
||||
// the dir+separator prefix via ordinal String methods, so a name-prefix sibling
|
||||
// (job1 vs job10) is spared and path metacharacters stay literal.
|
||||
func buildWindowsWorkspaceKillScript(dirs []string) string {
|
||||
quoted := make([]string, len(dirs))
|
||||
for i, d := range dirs {
|
||||
// Single-quoted PowerShell literal; escape ' by doubling it.
|
||||
quoted[i] = "'" + strings.ReplaceAll(d, "'", "''") + "'"
|
||||
}
|
||||
|
||||
return `$paths = @(` + strings.Join(quoted, ",") + `)
|
||||
$selfPid = $PID
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
|
||||
if ($_.ProcessId -eq $selfPid) { return $false }
|
||||
foreach ($p in $paths) {
|
||||
$prefix = $p + '\'
|
||||
if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
|
||||
if ($_.CommandLine -and $_.CommandLine.IndexOf($prefix, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
|
||||
}
|
||||
return $false
|
||||
} | ForEach-Object {
|
||||
& taskkill.exe /PID $_.ProcessId /T /F 2>$null | Out-Null
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
pids := make([]int, 0, len(e.runningPIDs))
|
||||
for pid := range e.runningPIDs {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
if len(pids) == 0 {
|
||||
// Detached: exec.CommandContext won't start on a cancelled ctx, and a
|
||||
// server cancel has already cancelled the parent ctx.
|
||||
killCtx, killCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer killCancel()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Dirs we own; a process referencing one is a leftover. ToolCache is shared
|
||||
// across jobs, and Workdir may be a caller-owned checkout.
|
||||
owned := []string{e.Path, e.TmpDir}
|
||||
if e.CleanWorkdir {
|
||||
owned = append(owned, e.Workdir)
|
||||
}
|
||||
dirs := make([]string, 0, len(owned))
|
||||
for _, d := range owned {
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dirs = append(dirs, abs)
|
||||
}
|
||||
if len(dirs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
for _, pid := range pids {
|
||||
// Best-effort: forcibly terminate process tree to release file handles
|
||||
// so that workspace cleanup can succeed on Windows.
|
||||
cmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
logger.Debugf("taskkill failed for pid=%d: %v output=%s", pid, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
script := buildWindowsWorkspaceKillScript(dirs)
|
||||
|
||||
cmd := exec.CommandContext(killCtx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// Win32_Process exposes no working directory, so the scan above misses a
|
||||
// process that merely runs in a workspace dir while pinning a handle on it.
|
||||
if killed, err := process.KillProcessesWithCWDUnder(killCtx, dirs); err != nil {
|
||||
logger.Debugf("workspace process kill by working directory reported errors: %v", err)
|
||||
} else if killed > 0 {
|
||||
logger.Debugf("terminated %d leftover process(es) by workspace working directory", killed)
|
||||
}
|
||||
}
|
||||
|
||||
// hostCleanupTimeout bounds each teardown phase so one stalled delete cannot
|
||||
// wedge the runner slot. A var so tests can shrink it.
|
||||
var hostCleanupTimeout = 30 * time.Second
|
||||
|
||||
// runWithTimeout returns context.DeadlineExceeded once timeout elapses, leaking
|
||||
// the goroutine: a delete blocked in a syscall (AV filter driver, dead network
|
||||
// mount) cannot be interrupted, and leaking scratch state beats losing the
|
||||
// runner's capacity slot forever. The idle stale-dir sweep reclaims it later.
|
||||
func runWithTimeout(fn func(), timeout time.Duration) error {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
fn()
|
||||
}()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
// Ensure any lingering child processes are ended before attempting
|
||||
// to remove the workspace (Windows file locks otherwise prevent cleanup).
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// End lingering processes before removing the workspace; on Windows their
|
||||
// file locks block cleanup. Closing the group is deterministic, the scan a net.
|
||||
if err := e.procGroup.Load().Close(); err != nil {
|
||||
logger.Debugf("closing the job's process group failed: %v", err)
|
||||
}
|
||||
e.terminateRunningProcesses(ctx)
|
||||
|
||||
// Only removes per-job misc state. Must not remove the cache/toolcache root.
|
||||
// Removes per-job misc state only, never the toolcache root. Bounded because
|
||||
// CleanUp is a caller-supplied, typically unbounded os.RemoveAll.
|
||||
if e.CleanUp != nil {
|
||||
e.CleanUp()
|
||||
logger.Debugf("running host environment cleanup callback")
|
||||
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
|
||||
logger.Warnf("host environment cleanup did not finish within %s; continuing job completion, scratch state may be leaked and is reclaimed by the idle stale-dir sweep", hostCleanupTimeout)
|
||||
} else {
|
||||
logger.Debugf("host environment cleanup callback finished")
|
||||
}
|
||||
}
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
|
||||
// which absorb Windows file-handle release lag after the kill above.
|
||||
rmCtx, rmCancel := context.WithTimeout(context.Background(), hostCleanupTimeout)
|
||||
defer rmCancel()
|
||||
|
||||
var errs []error
|
||||
if err := removePathWithRetry(ctx, e.Path); err != nil {
|
||||
if err := removePathWithRetry(rmCtx, e.Path); err != nil {
|
||||
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if !e.BindWorkdir && e.Workdir != "" {
|
||||
if err := removePathWithRetry(ctx, e.Workdir); err != nil {
|
||||
if e.CleanWorkdir {
|
||||
if err := removePathWithRetry(rmCtx, e.Workdir); err != nil {
|
||||
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
for _, err := range errs {
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
// Teardown timed out; warned above. Do not fail job completion over it.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,16 @@ package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
@@ -100,7 +103,46 @@ func TestHostEnvironmentExecExitCode(t *testing.T) {
|
||||
assert.Equal(t, "Process completed with exit code 3.", err.Error())
|
||||
}
|
||||
|
||||
func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) {
|
||||
func TestHostEnvironmentAllocatePTY(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
allocPTY bool
|
||||
expect string
|
||||
}{
|
||||
{name: "off", allocPTY: false, expect: "NOTTY"},
|
||||
{name: "on", allocPTY: true, expect: "TTY"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
buf := &bytes.Buffer{}
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||
ActPath: filepath.Join(dir, "act_path"),
|
||||
StdOut: buf,
|
||||
Workdir: filepath.Join(dir, "path"),
|
||||
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(
|
||||
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
|
||||
map[string]string{"PATH": os.Getenv("PATH")}, "", "",
|
||||
)(context.Background())
|
||||
require.NoError(t, err)
|
||||
got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
|
||||
assert.Equal(t, tc.expect, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
@@ -111,9 +153,8 @@ func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) {
|
||||
require.NoError(t, os.MkdirAll(workdir, 0o700))
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
Workdir: workdir,
|
||||
BindWorkdir: false,
|
||||
Path: path,
|
||||
Workdir: workdir,
|
||||
CleanUp: func() {
|
||||
_ = os.RemoveAll(miscRoot)
|
||||
},
|
||||
@@ -121,10 +162,10 @@ func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, e.Remove()(ctx))
|
||||
_, err := os.Stat(workdir)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) {
|
||||
func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
@@ -135,9 +176,9 @@ func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) {
|
||||
require.NoError(t, os.MkdirAll(workdir, 0o700))
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
Workdir: workdir,
|
||||
BindWorkdir: true,
|
||||
Path: path,
|
||||
Workdir: workdir,
|
||||
CleanWorkdir: true,
|
||||
CleanUp: func() {
|
||||
_ = os.RemoveAll(miscRoot)
|
||||
},
|
||||
@@ -145,5 +186,178 @@ func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, e.Remove()(ctx))
|
||||
_, err := os.Stat(workdir)
|
||||
require.NoError(t, err)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
stubDone := make(chan struct{})
|
||||
|
||||
orig := removeAll
|
||||
removeAll = func(string) error {
|
||||
defer close(stubDone)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
// removeAllWithContext intentionally leaks the delete goroutine on timeout,
|
||||
// and that goroutine still references removeAll. Unblock it and wait for it
|
||||
// to return before restoring the var, so the restore can't race the read.
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
<-stubDone
|
||||
removeAll = orig
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := removeAllWithContext(ctx, t.TempDir())
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp guards against a stalled
|
||||
// CleanUp callback (e.g. an os.RemoveAll blocked by an AV/EDR filter driver or
|
||||
// an unresponsive mount) wedging the runner slot forever at "Cleaning up
|
||||
// container". Remove must time out the callback and complete job teardown.
|
||||
func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) {
|
||||
// Keep the suite fast: shrink the per-phase teardown timeout for this test.
|
||||
orig := hostCleanupTimeout
|
||||
hostCleanupTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { hostCleanupTimeout = orig })
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "misc", "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() { close(release) }) // unblock the leaked goroutine at test end
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
CleanUp: func() {
|
||||
<-release // simulate a delete syscall stuck indefinitely
|
||||
},
|
||||
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 CleanUp callback")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
|
||||
t.Run("single dir", func(t *testing.T) {
|
||||
s := buildWindowsWorkspaceKillScript([]string{`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, "$_.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, "$_.ExecutablePath.StartsWith($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")
|
||||
})
|
||||
|
||||
t.Run("multiple dirs comma-separated", func(t *testing.T) {
|
||||
s := buildWindowsWorkspaceKillScript([]string{
|
||||
`C:\work\path`,
|
||||
`C:\work\workdir`,
|
||||
`C:\Users\runner\AppData\Local\Temp\job-42`,
|
||||
})
|
||||
assert.Contains(t, s, `'C:\work\path'`)
|
||||
assert.Contains(t, s, `'C:\work\workdir'`)
|
||||
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',`)
|
||||
})
|
||||
|
||||
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`})
|
||||
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'`)
|
||||
})
|
||||
|
||||
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`})
|
||||
assert.Contains(t, s, `'C:\work\[job]?1'`)
|
||||
assert.NotContains(t, s, "-like")
|
||||
assert.Contains(t, s, "StartsWith")
|
||||
assert.Contains(t, s, "IndexOf")
|
||||
})
|
||||
|
||||
t.Run("empty dir list still produces a valid script", func(t *testing.T) {
|
||||
s := buildWindowsWorkspaceKillScript(nil)
|
||||
// Empty array literal — script runs, matches nothing, is a no-op.
|
||||
assert.Contains(t, s, "$paths = @()")
|
||||
assert.Contains(t, s, "Get-CimInstance Win32_Process")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,12 +66,15 @@ func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) st
|
||||
return strings.Join(paths, ":")
|
||||
}
|
||||
|
||||
// DefaultToolCache is where the runner mounts the tool cache inside job containers.
|
||||
const DefaultToolCache = "/opt/hostedtoolcache"
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
||||
return map[string]any{
|
||||
"os": "Linux",
|
||||
"arch": RunnerArch(ctx),
|
||||
"temp": "/tmp",
|
||||
"tool_cache": "/opt/hostedtoolcache",
|
||||
"tool_cache": DefaultToolCache,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
|
||||
@@ -28,9 +31,19 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
s := bufio.NewScanner(reader)
|
||||
// Decode by BOM: Windows PowerShell 5.1 redirection writes UTF-16, and some
|
||||
// tools emit a UTF-8 BOM. Without a BOM the file is read as UTF-8, as before.
|
||||
decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
|
||||
|
||||
s := bufio.NewScanner(decoded)
|
||||
// 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)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
// GitHub's runner ignores blank lines
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
singleLineEnv := strings.Index(line, "=")
|
||||
multiLineEnv := strings.Index(line, "<<")
|
||||
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
|
||||
@@ -50,6 +63,9 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
||||
}
|
||||
multiLineEnvContent += content
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return fmt.Errorf("reading env file: %w", err)
|
||||
}
|
||||
if !delimiterFound {
|
||||
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
||||
}
|
||||
@@ -58,6 +74,9 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||
}
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return fmt.Errorf("reading env file: %w", err)
|
||||
}
|
||||
env = &localEnv
|
||||
return nil
|
||||
}
|
||||
|
||||
134
act/container/parse_env_file_test.go
Normal file
134
act/container/parse_env_file_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
)
|
||||
|
||||
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
|
||||
t.Helper()
|
||||
e := &HostEnvironment{Path: t.TempDir()}
|
||||
return e, filepath.Join(e.Path, "envfile")
|
||||
}
|
||||
|
||||
func TestParseEnvFileSingleLine(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "bar", env["FOO"])
|
||||
assert.Equal(t, "qux", env["BAZ"])
|
||||
}
|
||||
|
||||
func TestParseEnvFileMultiLine(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
content := "FOO<<EOF\nline1\nline2\nEOF\n"
|
||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "line1\nline2", env["FOO"])
|
||||
}
|
||||
|
||||
func TestParseEnvFileLargeValueWithinLimit(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
big := strings.Repeat("x", 2*1024*1024)
|
||||
content := "FOO<<EOF\n" + big + "\nEOF\n"
|
||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, big, env["FOO"])
|
||||
}
|
||||
|
||||
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
tooBig := strings.Repeat("x", 17*1024*1024) // over the 16 MiB cap
|
||||
content := "FOO<<EOF\n" + tooBig + "\nEOF\n"
|
||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
err := parseEnvFile(e, envPath, &env)(context.Background())
|
||||
require.ErrorIs(t, err, bufio.ErrTooLong)
|
||||
assert.Contains(t, err.Error(), "reading env file")
|
||||
}
|
||||
|
||||
// Regression test: a blank line used to fail the job at "Complete Job", after
|
||||
// every step had already been recorded as successful.
|
||||
func TestParseEnvFileBlankLines(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
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)(context.Background()))
|
||||
assert.Equal(t, "line1\n\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)(context.Background()))
|
||||
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)
|
||||
content, err := tt.encoder.Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "bar", env["FOO"])
|
||||
assert.Equal(t, "line1", env["MULTI"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "delimiter")
|
||||
}
|
||||
@@ -8,23 +8,10 @@ package container
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
func getSysProcAttr(_ string, tty bool) *syscall.SysProcAttr {
|
||||
if tty {
|
||||
return &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
Setctty: true,
|
||||
}
|
||||
}
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return pty.Open()
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ package container
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
|
||||
@@ -7,15 +7,8 @@ package container
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Rfork: syscall.RFNOTEG,
|
||||
}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
|
||||
@@ -7,13 +7,8 @@ package container
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getSysProcAttr(cmdLine string, tty bool) *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{CmdLine: cmdLine, CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package exprparser
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/rhysd/actionlint"
|
||||
)
|
||||
|
||||
func (impl *interperterImpl) contains(search, item reflect.Value) (bool, error) {
|
||||
switch search.Kind() {
|
||||
case reflect.String, reflect.Int, reflect.Float64, reflect.Bool, reflect.Invalid:
|
||||
return strings.Contains(
|
||||
strings.ToLower(impl.coerceToString(search).String()),
|
||||
strings.ToLower(impl.coerceToString(item).String()),
|
||||
), nil
|
||||
|
||||
case reflect.Slice:
|
||||
for i := 0; i < search.Len(); i++ {
|
||||
arrayItem := search.Index(i).Elem()
|
||||
result, err := impl.compareValues(arrayItem, item, actionlint.CompareOpNodeKindEq)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if isEqual, ok := result.(bool); ok && isEqual {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) startsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return strings.HasPrefix(
|
||||
strings.ToLower(impl.coerceToString(searchString).String()),
|
||||
strings.ToLower(impl.coerceToString(searchValue).String()),
|
||||
), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return strings.HasSuffix(
|
||||
strings.ToLower(impl.coerceToString(searchString).String()),
|
||||
strings.ToLower(impl.coerceToString(searchValue).String()),
|
||||
), nil
|
||||
}
|
||||
|
||||
const (
|
||||
passThrough = iota
|
||||
bracketOpen
|
||||
bracketClose
|
||||
)
|
||||
|
||||
func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
|
||||
input := impl.coerceToString(str).String()
|
||||
var output strings.Builder
|
||||
replacementIndex := ""
|
||||
|
||||
state := passThrough
|
||||
for _, character := range input {
|
||||
switch state {
|
||||
case passThrough: // normal buffer output
|
||||
switch character {
|
||||
case '{':
|
||||
state = bracketOpen
|
||||
|
||||
case '}':
|
||||
state = bracketClose
|
||||
|
||||
default:
|
||||
output.WriteRune(character)
|
||||
}
|
||||
|
||||
case bracketOpen: // found {
|
||||
switch character {
|
||||
case '{':
|
||||
output.WriteString("{")
|
||||
replacementIndex = ""
|
||||
state = passThrough
|
||||
|
||||
case '}':
|
||||
index, err := strconv.ParseInt(replacementIndex, 10, 32)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("The following format string is invalid: '%s'", input)
|
||||
}
|
||||
|
||||
replacementIndex = ""
|
||||
|
||||
if len(replaceValue) <= int(index) {
|
||||
return "", fmt.Errorf("The following format string references more arguments than were supplied: '%s'", input)
|
||||
}
|
||||
|
||||
output.WriteString(impl.coerceToString(replaceValue[index]).String())
|
||||
|
||||
state = passThrough
|
||||
|
||||
default:
|
||||
replacementIndex += string(character)
|
||||
}
|
||||
|
||||
case bracketClose: // found }
|
||||
switch character {
|
||||
case '}':
|
||||
output.WriteString("}")
|
||||
replacementIndex = ""
|
||||
state = passThrough
|
||||
|
||||
default:
|
||||
panic("Invalid format parser state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if state != passThrough {
|
||||
switch state {
|
||||
case bracketOpen:
|
||||
return "", fmt.Errorf("Unclosed brackets. The following format string is invalid: '%s'", input)
|
||||
|
||||
case bracketClose:
|
||||
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
||||
}
|
||||
}
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) join(array, sep reflect.Value) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
separator := impl.coerceToString(sep).String()
|
||||
switch array.Kind() {
|
||||
case reflect.Slice:
|
||||
var items []string
|
||||
for i := 0; i < array.Len(); i++ {
|
||||
items = append(items, impl.coerceToString(array.Index(i).Elem()).String())
|
||||
}
|
||||
|
||||
return strings.Join(items, separator), nil
|
||||
default:
|
||||
return strings.Join([]string{impl.coerceToString(array).String()}, separator), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) toJSON(value reflect.Value) (string, error) {
|
||||
if value.Kind() == reflect.Invalid {
|
||||
return "null", nil
|
||||
}
|
||||
|
||||
json, err := json.MarshalIndent(value.Interface(), "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Cannot convert value to JSON. Cause: %v", err)
|
||||
}
|
||||
|
||||
return string(json), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) fromJSON(value reflect.Value) (any, error) {
|
||||
if value.Kind() != reflect.String {
|
||||
return nil, fmt.Errorf("Cannot parse non-string type %v as JSON", value.Kind())
|
||||
}
|
||||
|
||||
var data any
|
||||
|
||||
err := json.Unmarshal([]byte(value.String()), &data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) hashFiles(paths ...reflect.Value) (string, error) {
|
||||
var ps []gitignore.Pattern
|
||||
|
||||
const cwdPrefix = "." + string(filepath.Separator)
|
||||
const excludeCwdPrefix = "!" + cwdPrefix
|
||||
for _, path := range paths {
|
||||
if path.Kind() == reflect.String {
|
||||
cleanPath := path.String()
|
||||
if strings.HasPrefix(cleanPath, cwdPrefix) {
|
||||
cleanPath = cleanPath[len(cwdPrefix):]
|
||||
} else if strings.HasPrefix(cleanPath, excludeCwdPrefix) {
|
||||
cleanPath = "!" + cleanPath[len(excludeCwdPrefix):]
|
||||
}
|
||||
ps = append(ps, gitignore.ParsePattern(cleanPath, nil))
|
||||
} else {
|
||||
return "", errors.New("Non-string path passed to hashFiles")
|
||||
}
|
||||
}
|
||||
|
||||
matcher := gitignore.NewMatcher(ps)
|
||||
|
||||
var files []string
|
||||
if err := filepath.Walk(impl.config.WorkingDir, func(path string, fi fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sansPrefix := strings.TrimPrefix(path, impl.config.WorkingDir+string(filepath.Separator))
|
||||
parts := strings.Split(sansPrefix, string(filepath.Separator))
|
||||
if fi.IsDir() || !matcher.Match(parts, fi.IsDir()) {
|
||||
return nil
|
||||
}
|
||||
files = append(files, path)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("Unable to filepath.Walk: %v", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Unable to os.Open: %v", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(hasher, f); err != nil {
|
||||
return "", fmt.Errorf("Unable to io.Copy: %v", err)
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
return "", fmt.Errorf("Unable to Close file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
|
||||
needs := job.Needs()
|
||||
|
||||
for _, need := range needs {
|
||||
parentNeeds := impl.getNeedsTransitive(impl.config.Run.Workflow.GetJob(need))
|
||||
needs = append(needs, parentNeeds...)
|
||||
}
|
||||
|
||||
return needs
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) always() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
jobs := impl.config.Run.Workflow.Jobs
|
||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||
|
||||
for _, needs := range jobNeeds {
|
||||
if jobs[needs].Result != "success" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return impl.env.Job.Status == "success", nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
jobs := impl.config.Run.Workflow.Jobs
|
||||
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||
|
||||
for _, needs := range jobNeeds {
|
||||
if jobs[needs].Result == "failure" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return impl.env.Job.Status == "failure", nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return impl.env.Job.Status == "cancelled", nil
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package exprparser
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFunctionContains(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"contains('search', 'item') }}", false, "contains-str-str"},
|
||||
{`cOnTaInS('Hello', 'll') }}`, true, "contains-str-casing"},
|
||||
{`contains('HELLO', 'll') }}`, true, "contains-str-casing"},
|
||||
{`contains('3.141592', 3.14) }}`, true, "contains-str-number"},
|
||||
{`contains(3.141592, '3.14') }}`, true, "contains-number-str"},
|
||||
{`contains(3.141592, 3.14) }}`, true, "contains-number-number"},
|
||||
{`contains(true, 'u') }}`, true, "contains-bool-str"},
|
||||
{`contains(null, '') }}`, true, "contains-null-str"},
|
||||
{`contains(fromJSON('["first","second"]'), 'first') }}`, true, "contains-item"},
|
||||
{`contains(fromJSON('[null,"second"]'), '') }}`, true, "contains-item-null-empty-str"},
|
||||
{`contains(fromJSON('["","second"]'), null) }}`, true, "contains-item-empty-str-null"},
|
||||
{`contains(fromJSON('[true,"second"]'), 'true') }}`, false, "contains-item-bool-arr"},
|
||||
{`contains(fromJSON('["true","second"]'), true) }}`, false, "contains-item-str-bool"},
|
||||
{`contains(fromJSON('[3.14,"second"]'), '3.14') }}`, true, "contains-item-number-str"},
|
||||
{`contains(fromJSON('[3.14,"second"]'), 3.14) }}`, true, "contains-item-number-number"},
|
||||
{`contains(fromJSON('["","second"]'), fromJSON('[]')) }}`, false, "contains-item-str-arr"},
|
||||
{`contains(fromJSON('["","second"]'), fromJSON('{}')) }}`, false, "contains-item-str-obj"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionStartsWith(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"startsWith('search', 'se') }}", true, "startswith-string"},
|
||||
{"startsWith('search', 'sa') }}", false, "startswith-string"},
|
||||
{"startsWith('123search', '123s') }}", true, "startswith-string"},
|
||||
{"startsWith(123, 's') }}", false, "startswith-string"},
|
||||
{"startsWith(123, '12') }}", true, "startswith-string"},
|
||||
{"startsWith('123', 12) }}", true, "startswith-string"},
|
||||
{"startsWith(null, '42') }}", false, "startswith-string"},
|
||||
{"startsWith('null', null) }}", true, "startswith-string"},
|
||||
{"startsWith('null', '') }}", true, "startswith-string"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionEndsWith(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"endsWith('search', 'ch') }}", true, "endsWith-string"},
|
||||
{"endsWith('search', 'sa') }}", false, "endsWith-string"},
|
||||
{"endsWith('search123s', '123s') }}", true, "endsWith-string"},
|
||||
{"endsWith(123, 's') }}", false, "endsWith-string"},
|
||||
{"endsWith(123, '23') }}", true, "endsWith-string"},
|
||||
{"endsWith('123', 23) }}", true, "endsWith-string"},
|
||||
{"endsWith(null, '42') }}", false, "endsWith-string"},
|
||||
{"endsWith('null', null) }}", true, "endsWith-string"},
|
||||
{"endsWith('null', '') }}", true, "endsWith-string"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionJoin(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"join(fromJSON('[\"a\", \"b\"]'), ',')", "a,b", "join-arr"},
|
||||
{"join('string', ',')", "string", "join-str"},
|
||||
{"join(1, ',')", "1", "join-number"},
|
||||
{"join(null, ',')", "", "join-number"},
|
||||
{"join(fromJSON('[\"a\", \"b\", null]'), null)", "ab", "join-number"},
|
||||
{"join(fromJSON('[\"a\", \"b\"]'))", "a,b", "join-number"},
|
||||
{"join(fromJSON('[\"a\", \"b\", null]'), 1)", "a1b1", "join-number"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionToJSON(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"toJSON(env) }}", "{\n \"key\": \"value\"\n}", "toJSON"},
|
||||
{"toJSON(null)", "null", "toJSON-null"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Env: map[string]string{
|
||||
"key": "value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionFromJSON(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"fromJSON('{\"foo\":\"bar\"}') }}", map[string]any{
|
||||
"foo": "bar",
|
||||
}, "fromJSON"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionHashFiles(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"hashFiles('**/non-extant-files') }}", "", "hash-non-existing-file"},
|
||||
{"hashFiles('**/non-extant-files', '**/more-non-extant-files') }}", "", "hash-multiple-non-existing-files"},
|
||||
{"hashFiles('./for-hashing-1.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-single-file"},
|
||||
{"hashFiles('./for-hashing-*.txt') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"},
|
||||
{"hashFiles('./for-hashing-*.txt', '!./for-hashing-2.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-negative-pattern"},
|
||||
{"hashFiles('./for-hashing-**') }}", "c418ba693753c84115ced0da77f876cddc662b9054f4b129b90f822597ee2f94", "hash-multiple-files-and-directories"},
|
||||
{"hashFiles('./for-hashing-3/**') }}", "6f5696b546a7a9d6d42a449dc9a56bef244aaa826601ef27466168846139d2c2", "hash-nested-directories"},
|
||||
{"hashFiles('./for-hashing-3/**/nested-data.txt') }}", "8ecadfb49f7f978d0a9f3a957e9c8da6cc9ab871f5203b5d9f9d1dc87d8af18c", "hash-nested-directories-2"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
workdir, err := filepath.Abs("testdata")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunctionFormat(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
error any
|
||||
name string
|
||||
}{
|
||||
{"format('text')", "text", nil, "format-plain-string"},
|
||||
{"format('Hello {0} {1} {2}!', 'Mona', 'the', 'Octocat')", "Hello Mona the Octocat!", nil, "format-with-placeholders"},
|
||||
{"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "{Hello Mona the Octocat!}", nil, "format-with-escaped-braces"},
|
||||
{"format('{{0}}', 'test')", "{0}", nil, "format-with-escaped-braces"},
|
||||
{"format('{{{0}}}', 'test')", "{test}", nil, "format-with-escaped-braces-and-value"},
|
||||
{"format('}}')", "}", nil, "format-output-closing-brace"},
|
||||
{`format('Hello "{0}" {1} {2} {3} {4}', null, true, -3.14, NaN, Infinity)`, `Hello "" true -3.14 NaN Infinity`, nil, "format-with-primitives"},
|
||||
{`format('Hello "{0}" {1} {2}', fromJSON('[0, true, "abc"]'), fromJSON('[{"a":1}]'), fromJSON('{"a":{"b":1}}'))`, `Hello "Array" Array Object`, nil, "format-with-complex-types"},
|
||||
{"format(true)", "true", nil, "format-with-primitive-args"},
|
||||
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", nil, "format-with-undefined-value"},
|
||||
{"format('{0}}', '{1}', 'World')", nil, "Closing bracket without opening one. The following format string is invalid: '{0}}'", "format-invalid-format-string"},
|
||||
{"format('{0', '{1}', 'World')", nil, "Unclosed brackets. The following format string is invalid: '{0'", "format-invalid-format-string"},
|
||||
{"format('{2}', '{1}', 'World')", "", "The following format string references more arguments than were supplied: '{2}'", "format-invalid-replacement-reference"},
|
||||
{"format('{2147483648}')", "", "The following format string is invalid: '{2147483648}'", "format-invalid-replacement-reference"},
|
||||
{"format('{0} {1} {2} {3}', 1.0, 1.1, 1234567890.0, 12345678901234567890.0)", "1 1.1 1234567890 1.23456789012346E+19", nil, "format-floats"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
if tt.error != nil {
|
||||
assert.Equal(t, tt.error, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, tt.expected, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package exprparser
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/rhysd/actionlint"
|
||||
)
|
||||
|
||||
type EvaluationEnvironment struct {
|
||||
Github *model.GithubContext
|
||||
Env map[string]string
|
||||
Job *model.JobContext
|
||||
Jobs *map[string]*model.WorkflowCallResult
|
||||
Steps map[string]*model.StepResult
|
||||
Runner map[string]any
|
||||
Secrets map[string]string
|
||||
Vars map[string]string
|
||||
Strategy map[string]any
|
||||
Matrix map[string]any
|
||||
Needs map[string]Needs
|
||||
Inputs map[string]any
|
||||
HashFiles func([]reflect.Value) (any, error)
|
||||
}
|
||||
|
||||
type Needs struct {
|
||||
Outputs map[string]string `json:"outputs"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Run *model.Run
|
||||
WorkingDir string
|
||||
Context string
|
||||
}
|
||||
|
||||
type DefaultStatusCheck int
|
||||
|
||||
const (
|
||||
DefaultStatusCheckNone DefaultStatusCheck = iota
|
||||
DefaultStatusCheckSuccess
|
||||
DefaultStatusCheckAlways
|
||||
DefaultStatusCheckCanceled
|
||||
DefaultStatusCheckFailure
|
||||
)
|
||||
|
||||
func (dsc DefaultStatusCheck) String() string {
|
||||
switch dsc {
|
||||
case DefaultStatusCheckSuccess:
|
||||
return "success"
|
||||
case DefaultStatusCheckAlways:
|
||||
return "always"
|
||||
case DefaultStatusCheckCanceled:
|
||||
return "cancelled"
|
||||
case DefaultStatusCheckFailure:
|
||||
return "failure"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Interpreter interface {
|
||||
Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error)
|
||||
}
|
||||
|
||||
type interperterImpl struct {
|
||||
env *EvaluationEnvironment
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
|
||||
return &interperterImpl{
|
||||
env: env,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
|
||||
input = strings.TrimPrefix(input, "${{")
|
||||
if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
|
||||
input = "success()"
|
||||
}
|
||||
parser := actionlint.NewExprParser()
|
||||
exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to parse: %s", err.Message)
|
||||
}
|
||||
|
||||
if defaultStatusCheck != DefaultStatusCheckNone {
|
||||
hasStatusCheckFunction := false
|
||||
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
|
||||
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
|
||||
switch strings.ToLower(funcCallNode.Callee) {
|
||||
case "success", "always", "cancelled", "failure":
|
||||
hasStatusCheckFunction = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if !hasStatusCheckFunction {
|
||||
exprNode = &actionlint.LogicalOpNode{
|
||||
Kind: actionlint.LogicalOpNodeKindAnd,
|
||||
Left: &actionlint.FuncCallNode{
|
||||
Callee: defaultStatusCheck.String(),
|
||||
Args: []actionlint.ExprNode{},
|
||||
},
|
||||
Right: exprNode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, err2 := impl.evaluateNode(exprNode)
|
||||
|
||||
return result, err2
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
|
||||
switch node := exprNode.(type) {
|
||||
case *actionlint.VariableNode:
|
||||
return impl.evaluateVariable(node)
|
||||
case *actionlint.BoolNode:
|
||||
return node.Value, nil
|
||||
case *actionlint.NullNode:
|
||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||
case *actionlint.IntNode:
|
||||
return node.Value, nil
|
||||
case *actionlint.FloatNode:
|
||||
return node.Value, nil
|
||||
case *actionlint.StringNode:
|
||||
return node.Value, nil
|
||||
case *actionlint.IndexAccessNode:
|
||||
return impl.evaluateIndexAccess(node)
|
||||
case *actionlint.ObjectDerefNode:
|
||||
return impl.evaluateObjectDeref(node)
|
||||
case *actionlint.ArrayDerefNode:
|
||||
return impl.evaluateArrayDeref(node)
|
||||
case *actionlint.NotOpNode:
|
||||
return impl.evaluateNot(node)
|
||||
case *actionlint.CompareOpNode:
|
||||
return impl.evaluateCompare(node)
|
||||
case *actionlint.LogicalOpNode:
|
||||
return impl.evaluateLogicalCompare(node)
|
||||
case *actionlint.FuncCallNode:
|
||||
return impl.evaluateFuncCall(node)
|
||||
default:
|
||||
return nil, fmt.Errorf("Fatal error! Unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
|
||||
switch strings.ToLower(variableNode.Name) {
|
||||
case "github":
|
||||
return impl.env.Github, nil
|
||||
case "gitea": // compatible with Gitea
|
||||
return impl.env.Github, nil
|
||||
case "env":
|
||||
return impl.env.Env, nil
|
||||
case "job":
|
||||
return impl.env.Job, nil
|
||||
case "jobs":
|
||||
if impl.env.Jobs == nil {
|
||||
return nil, errors.New("Unavailable context: jobs")
|
||||
}
|
||||
return impl.env.Jobs, nil
|
||||
case "steps":
|
||||
return impl.env.Steps, nil
|
||||
case "runner":
|
||||
return impl.env.Runner, nil
|
||||
case "secrets":
|
||||
return impl.env.Secrets, nil
|
||||
case "vars":
|
||||
return impl.env.Vars, nil
|
||||
case "strategy":
|
||||
return impl.env.Strategy, nil
|
||||
case "matrix":
|
||||
return impl.env.Matrix, nil
|
||||
case "needs":
|
||||
return impl.env.Needs, nil
|
||||
case "inputs":
|
||||
return impl.env.Inputs, nil
|
||||
case "infinity":
|
||||
return math.Inf(1), nil
|
||||
case "nan":
|
||||
return math.NaN(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("Unavailable context: %s", variableNode.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (any, error) {
|
||||
left, err := impl.evaluateNode(indexAccessNode.Operand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
leftValue := reflect.ValueOf(left)
|
||||
|
||||
right, err := impl.evaluateNode(indexAccessNode.Index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rightValue := reflect.ValueOf(right)
|
||||
|
||||
switch rightValue.Kind() {
|
||||
case reflect.String:
|
||||
return impl.getPropertyValue(leftValue, rightValue.String())
|
||||
|
||||
case reflect.Int:
|
||||
switch leftValue.Kind() {
|
||||
case reflect.Slice:
|
||||
if rightValue.Int() < 0 || rightValue.Int() >= int64(leftValue.Len()) {
|
||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||
}
|
||||
return leftValue.Index(int(rightValue.Int())).Interface(), nil
|
||||
default:
|
||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateObjectDeref(objectDerefNode *actionlint.ObjectDerefNode) (any, error) {
|
||||
left, err := impl.evaluateNode(objectDerefNode.Receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return impl.getPropertyValue(reflect.ValueOf(left), objectDerefNode.Property)
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.ArrayDerefNode) (any, error) {
|
||||
left, err := impl.evaluateNode(arrayDerefNode.Receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return impl.getSafeValue(reflect.ValueOf(left)), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
|
||||
switch left.Kind() {
|
||||
case reflect.Ptr:
|
||||
return impl.getPropertyValue(left.Elem(), property)
|
||||
|
||||
case reflect.Struct:
|
||||
leftType := left.Type()
|
||||
for field := range leftType.Fields() {
|
||||
jsonName := field.Tag.Get("json")
|
||||
if jsonName == property {
|
||||
property = field.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fieldValue := left.FieldByNameFunc(func(name string) bool {
|
||||
return strings.EqualFold(name, property)
|
||||
})
|
||||
|
||||
if fieldValue.Kind() == reflect.Invalid {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
i := fieldValue.Interface()
|
||||
// The type stepStatus int is an integer, but should be treated as string
|
||||
if m, ok := i.(encoding.TextMarshaler); ok {
|
||||
text, err := m.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(text), nil
|
||||
}
|
||||
return i, nil
|
||||
|
||||
case reflect.Map:
|
||||
iter := left.MapRange()
|
||||
|
||||
for iter.Next() {
|
||||
key := iter.Key()
|
||||
|
||||
switch key.Kind() {
|
||||
case reflect.String:
|
||||
if strings.EqualFold(key.String(), property) {
|
||||
return impl.getMapValue(iter.Value())
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("'%s' in map key not implemented", key.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||
|
||||
case reflect.Slice:
|
||||
var values []any
|
||||
|
||||
for i := 0; i < left.Len(); i++ {
|
||||
value, err := impl.getPropertyValue(left.Index(i).Elem(), property)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
|
||||
if value.Kind() == reflect.Ptr {
|
||||
return impl.getMapValue(value.Elem())
|
||||
}
|
||||
|
||||
return value.Interface(), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateNot(notNode *actionlint.NotOpNode) (any, error) {
|
||||
operand, err := impl.evaluateNode(notNode.Operand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return !IsTruthy(operand), nil
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateCompare(compareNode *actionlint.CompareOpNode) (any, error) {
|
||||
left, err := impl.evaluateNode(compareNode.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
right, err := impl.evaluateNode(compareNode.Right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
leftValue := reflect.ValueOf(left)
|
||||
rightValue := reflect.ValueOf(right)
|
||||
|
||||
return impl.compareValues(leftValue, rightValue, compareNode.Kind)
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) compareValues(leftValue, rightValue reflect.Value, kind actionlint.CompareOpNodeKind) (any, error) {
|
||||
if leftValue.Kind() != rightValue.Kind() {
|
||||
if !impl.isNumber(leftValue) {
|
||||
leftValue = impl.coerceToNumber(leftValue)
|
||||
}
|
||||
if !impl.isNumber(rightValue) {
|
||||
rightValue = impl.coerceToNumber(rightValue)
|
||||
}
|
||||
}
|
||||
|
||||
switch leftValue.Kind() {
|
||||
case reflect.Bool:
|
||||
return impl.compareNumber(float64(impl.coerceToNumber(leftValue).Int()), float64(impl.coerceToNumber(rightValue).Int()), kind)
|
||||
case reflect.String:
|
||||
return impl.compareString(strings.ToLower(leftValue.String()), strings.ToLower(rightValue.String()), kind)
|
||||
|
||||
case reflect.Int:
|
||||
if rightValue.Kind() == reflect.Float64 {
|
||||
return impl.compareNumber(float64(leftValue.Int()), rightValue.Float(), kind)
|
||||
}
|
||||
|
||||
return impl.compareNumber(float64(leftValue.Int()), float64(rightValue.Int()), kind)
|
||||
|
||||
case reflect.Float64:
|
||||
if rightValue.Kind() == reflect.Int {
|
||||
return impl.compareNumber(leftValue.Float(), float64(rightValue.Int()), kind)
|
||||
}
|
||||
|
||||
return impl.compareNumber(leftValue.Float(), rightValue.Float(), kind)
|
||||
|
||||
case reflect.Invalid:
|
||||
if rightValue.Kind() == reflect.Invalid {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// not possible situation - params are converted to the same type in code above
|
||||
return nil, fmt.Errorf("Compare params of Invalid type: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("Compare not implemented for types: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) coerceToNumber(value reflect.Value) reflect.Value {
|
||||
switch value.Kind() {
|
||||
case reflect.Invalid:
|
||||
return reflect.ValueOf(0)
|
||||
|
||||
case reflect.Bool:
|
||||
switch value.Bool() {
|
||||
case true:
|
||||
return reflect.ValueOf(1)
|
||||
case false:
|
||||
return reflect.ValueOf(0)
|
||||
}
|
||||
|
||||
case reflect.String:
|
||||
if value.String() == "" {
|
||||
return reflect.ValueOf(0)
|
||||
}
|
||||
|
||||
// try to parse the string as a number
|
||||
evaluated, err := impl.Evaluate(value.String(), DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return reflect.ValueOf(math.NaN())
|
||||
}
|
||||
|
||||
if value := reflect.ValueOf(evaluated); impl.isNumber(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return reflect.ValueOf(math.NaN())
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) coerceToString(value reflect.Value) reflect.Value {
|
||||
switch value.Kind() {
|
||||
case reflect.Invalid:
|
||||
return reflect.ValueOf("")
|
||||
|
||||
case reflect.Bool:
|
||||
switch value.Bool() {
|
||||
case true:
|
||||
return reflect.ValueOf("true")
|
||||
case false:
|
||||
return reflect.ValueOf("false")
|
||||
}
|
||||
|
||||
case reflect.String:
|
||||
return value
|
||||
|
||||
case reflect.Int:
|
||||
return reflect.ValueOf(fmt.Sprint(value))
|
||||
|
||||
case reflect.Float64:
|
||||
if math.IsInf(value.Float(), 1) {
|
||||
return reflect.ValueOf("Infinity")
|
||||
} else if math.IsInf(value.Float(), -1) {
|
||||
return reflect.ValueOf("-Infinity")
|
||||
}
|
||||
return reflect.ValueOf(fmt.Sprintf("%.15G", value.Float()))
|
||||
|
||||
case reflect.Slice:
|
||||
return reflect.ValueOf("Array")
|
||||
|
||||
case reflect.Map:
|
||||
return reflect.ValueOf("Object")
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) compareString(left, right string, kind actionlint.CompareOpNodeKind) (bool, error) {
|
||||
switch kind {
|
||||
case actionlint.CompareOpNodeKindLess:
|
||||
return left < right, nil
|
||||
case actionlint.CompareOpNodeKindLessEq:
|
||||
return left <= right, nil
|
||||
case actionlint.CompareOpNodeKindGreater:
|
||||
return left > right, nil
|
||||
case actionlint.CompareOpNodeKindGreaterEq:
|
||||
return left >= right, nil
|
||||
case actionlint.CompareOpNodeKindEq:
|
||||
return left == right, nil
|
||||
case actionlint.CompareOpNodeKindNotEq:
|
||||
return left != right, nil
|
||||
default:
|
||||
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) compareNumber(left, right float64, kind actionlint.CompareOpNodeKind) (bool, error) {
|
||||
switch kind {
|
||||
case actionlint.CompareOpNodeKindLess:
|
||||
return left < right, nil
|
||||
case actionlint.CompareOpNodeKindLessEq:
|
||||
return left <= right, nil
|
||||
case actionlint.CompareOpNodeKindGreater:
|
||||
return left > right, nil
|
||||
case actionlint.CompareOpNodeKindGreaterEq:
|
||||
return left >= right, nil
|
||||
case actionlint.CompareOpNodeKindEq:
|
||||
return left == right, nil
|
||||
case actionlint.CompareOpNodeKindNotEq:
|
||||
return left != right, nil
|
||||
default:
|
||||
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func IsTruthy(input any) bool {
|
||||
value := reflect.ValueOf(input)
|
||||
switch value.Kind() {
|
||||
case reflect.Bool:
|
||||
return value.Bool()
|
||||
|
||||
case reflect.String:
|
||||
return value.String() != ""
|
||||
|
||||
case reflect.Int:
|
||||
return value.Int() != 0
|
||||
|
||||
case reflect.Float64:
|
||||
if math.IsNaN(value.Float()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return value.Float() != 0
|
||||
|
||||
case reflect.Map, reflect.Slice:
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) isNumber(value reflect.Value) bool {
|
||||
switch value.Kind() {
|
||||
case reflect.Int, reflect.Float64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) getSafeValue(value reflect.Value) any {
|
||||
switch value.Kind() {
|
||||
case reflect.Invalid:
|
||||
return nil
|
||||
|
||||
case reflect.Float64:
|
||||
if value.Float() == 0 {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return value.Interface()
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.LogicalOpNode) (any, error) {
|
||||
left, err := impl.evaluateNode(compareNode.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
leftValue := reflect.ValueOf(left)
|
||||
|
||||
if IsTruthy(left) == (compareNode.Kind == actionlint.LogicalOpNodeKindOr) {
|
||||
return impl.getSafeValue(leftValue), nil
|
||||
}
|
||||
|
||||
right, err := impl.evaluateNode(compareNode.Right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rightValue := reflect.ValueOf(right)
|
||||
|
||||
switch compareNode.Kind {
|
||||
case actionlint.LogicalOpNodeKindAnd:
|
||||
return impl.getSafeValue(rightValue), nil
|
||||
case actionlint.LogicalOpNodeKindOr:
|
||||
return impl.getSafeValue(rightValue), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
|
||||
}
|
||||
|
||||
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
|
||||
args := make([]reflect.Value, 0)
|
||||
|
||||
for _, arg := range funcCallNode.Args {
|
||||
value, err := impl.evaluateNode(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args = append(args, reflect.ValueOf(value))
|
||||
}
|
||||
|
||||
switch strings.ToLower(funcCallNode.Callee) {
|
||||
case "contains":
|
||||
return impl.contains(args[0], args[1])
|
||||
case "startswith":
|
||||
return impl.startsWith(args[0], args[1])
|
||||
case "endswith":
|
||||
return impl.endsWith(args[0], args[1])
|
||||
case "format":
|
||||
return impl.format(args[0], args[1:]...)
|
||||
case "join":
|
||||
if len(args) == 1 {
|
||||
return impl.join(args[0], reflect.ValueOf(","))
|
||||
}
|
||||
return impl.join(args[0], args[1])
|
||||
case "tojson":
|
||||
return impl.toJSON(args[0])
|
||||
case "fromjson":
|
||||
return impl.fromJSON(args[0])
|
||||
case "hashfiles":
|
||||
if impl.env.HashFiles != nil {
|
||||
return impl.env.HashFiles(args)
|
||||
}
|
||||
return impl.hashFiles(args...)
|
||||
case "always":
|
||||
return impl.always()
|
||||
case "success":
|
||||
if impl.config.Context == "job" {
|
||||
return impl.jobSuccess()
|
||||
}
|
||||
if impl.config.Context == "step" {
|
||||
return impl.stepSuccess()
|
||||
}
|
||||
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
||||
case "failure":
|
||||
if impl.config.Context == "job" {
|
||||
return impl.jobFailure()
|
||||
}
|
||||
if impl.config.Context == "step" {
|
||||
return impl.stepFailure()
|
||||
}
|
||||
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
||||
case "cancelled":
|
||||
return impl.cancelled()
|
||||
default:
|
||||
return nil, fmt.Errorf("TODO: '%s' not implemented", funcCallNode.Callee)
|
||||
}
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package exprparser
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLiterals(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"true", true, "true"},
|
||||
{"false", false, "false"},
|
||||
{"null", nil, "null"},
|
||||
{"123", 123, "integer"},
|
||||
{"-9.7", -9.7, "float"},
|
||||
{"0xff", 255, "hex"},
|
||||
{"-2.99e-2", -2.99e-2, "exponential"},
|
||||
{"'foo'", "foo", "string"},
|
||||
{"'it''s foo'", "it's foo", "string"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperators(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
error string
|
||||
}{
|
||||
{"(false || (false || true))", true, "logical-grouping", ""},
|
||||
{"github.action", "push", "property-dereference", ""},
|
||||
{"github['action']", "push", "property-index", ""},
|
||||
{"github.action[0]", nil, "string-index", ""},
|
||||
{"github.action['0']", nil, "string-index", ""},
|
||||
{"fromJSON('[0,1]')[1]", 1.0, "array-index", ""},
|
||||
{"fromJSON('[0,1]')[1.1]", nil, "array-index", ""},
|
||||
// Disabled weird things are happening
|
||||
// {"fromJSON('[0,1]')['1.1']", nil, "array-index", ""},
|
||||
{"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""},
|
||||
{"fromJSON('[0,1]')[2]", nil, "array-index-out-of-bounds-0", ""},
|
||||
{"fromJSON('[0,1]')[34553]", nil, "array-index-out-of-bounds-1", ""},
|
||||
{"fromJSON('[0,1]')[-1]", nil, "array-index-out-of-bounds-2", ""},
|
||||
{"fromJSON('[0,1]')[-34553]", nil, "array-index-out-of-bounds-3", ""},
|
||||
{"!true", false, "not", ""},
|
||||
{"1 < 2", true, "less-than", ""},
|
||||
{`'b' <= 'a'`, false, "less-than-or-equal", ""},
|
||||
{"1 > 2", false, "greater-than", ""},
|
||||
{`'b' >= 'a'`, true, "greater-than-or-equal", ""},
|
||||
{`'a' == 'a'`, true, "equal", ""},
|
||||
{`'a' != 'a'`, false, "not-equal", ""},
|
||||
{`true && false`, false, "and", ""},
|
||||
{`true || false`, true, "or", ""},
|
||||
{`fromJSON('{}') && true`, true, "and-boolean-object", ""},
|
||||
{`fromJSON('{}') || false`, make(map[string]any), "or-boolean-object", ""},
|
||||
{"github.event.commits[0].author.username != github.event.commits[1].author.username", true, "property-comparison1", ""},
|
||||
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username", true, "property-comparison2", ""},
|
||||
{"github.event.commits[0].author.username != github.event.commits[1].author.username1", true, "property-comparison3", ""},
|
||||
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username2", true, "property-comparison4", ""},
|
||||
{"secrets != env", nil, "property-comparison5", "Compare not implemented for types: left: map, right: map"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{
|
||||
Action: "push",
|
||||
Event: map[string]any{
|
||||
"commits": []any{
|
||||
map[string]any{
|
||||
"author": map[string]any{
|
||||
"username": "someone",
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"author": map[string]any{
|
||||
"username": "someone-else",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
if tt.error != "" {
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, tt.error, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorsCompare(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"!null", true, "not-null"},
|
||||
{"!-10", false, "not-neg-num"},
|
||||
{"!0", true, "not-zero"},
|
||||
{"!3.14", false, "not-pos-float"},
|
||||
{"!''", true, "not-empty-str"},
|
||||
{"!'abc'", false, "not-str"},
|
||||
{"!fromJSON('{}')", false, "not-obj"},
|
||||
{"!fromJSON('[]')", false, "not-arr"},
|
||||
{`null == 0 }}`, true, "null-coercion"},
|
||||
{`true == 1 }}`, true, "boolean-coercion"},
|
||||
{`'' == 0 }}`, true, "string-0-coercion"},
|
||||
{`'3' == 3 }}`, true, "string-3-coercion"},
|
||||
{`0 == null }}`, true, "null-coercion-alt"},
|
||||
{`1 == true }}`, true, "boolean-coercion-alt"},
|
||||
{`0 == '' }}`, true, "string-0-coercion-alt"},
|
||||
{`3 == '3' }}`, true, "string-3-coercion-alt"},
|
||||
{`'TEST' == 'test' }}`, true, "string-casing"},
|
||||
{"true > false }}", true, "bool-greater-than"},
|
||||
{"true >= false }}", true, "bool-greater-than-eq"},
|
||||
{"true >= true }}", true, "bool-greater-than-1"},
|
||||
{"true != false }}", true, "bool-not-equal"},
|
||||
{`fromJSON('{}') < 2 }}`, false, "object-with-less"},
|
||||
{`fromJSON('{}') < fromJSON('[]') }}`, false, "object/arr-with-lt"},
|
||||
{`fromJSON('{}') > fromJSON('[]') }}`, false, "object/arr-with-gt"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{
|
||||
Action: "push",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorsBooleanEvaluation(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
// true &&
|
||||
{"true && true", true, "true-and"},
|
||||
{"true && false", false, "true-and"},
|
||||
{"true && null", nil, "true-and"},
|
||||
{"true && -10", -10, "true-and"},
|
||||
{"true && 0", 0, "true-and"},
|
||||
{"true && 10", 10, "true-and"},
|
||||
{"true && 3.14", 3.14, "true-and"},
|
||||
{"true && 0.0", 0, "true-and"},
|
||||
{"true && Infinity", math.Inf(1), "true-and"},
|
||||
// {"true && -Infinity", math.Inf(-1), "true-and"},
|
||||
{"true && NaN", math.NaN(), "true-and"},
|
||||
{"true && ''", "", "true-and"},
|
||||
{"true && 'abc'", "abc", "true-and"},
|
||||
// false &&
|
||||
{"false && true", false, "false-and"},
|
||||
{"false && false", false, "false-and"},
|
||||
{"false && null", false, "false-and"},
|
||||
{"false && -10", false, "false-and"},
|
||||
{"false && 0", false, "false-and"},
|
||||
{"false && 10", false, "false-and"},
|
||||
{"false && 3.14", false, "false-and"},
|
||||
{"false && 0.0", false, "false-and"},
|
||||
{"false && Infinity", false, "false-and"},
|
||||
// {"false && -Infinity", false, "false-and"},
|
||||
{"false && NaN", false, "false-and"},
|
||||
{"false && ''", false, "false-and"},
|
||||
{"false && 'abc'", false, "false-and"},
|
||||
// true ||
|
||||
{"true || true", true, "true-or"},
|
||||
{"true || false", true, "true-or"},
|
||||
{"true || null", true, "true-or"},
|
||||
{"true || -10", true, "true-or"},
|
||||
{"true || 0", true, "true-or"},
|
||||
{"true || 10", true, "true-or"},
|
||||
{"true || 3.14", true, "true-or"},
|
||||
{"true || 0.0", true, "true-or"},
|
||||
{"true || Infinity", true, "true-or"},
|
||||
// {"true || -Infinity", true, "true-or"},
|
||||
{"true || NaN", true, "true-or"},
|
||||
{"true || ''", true, "true-or"},
|
||||
{"true || 'abc'", true, "true-or"},
|
||||
// false ||
|
||||
{"false || true", true, "false-or"},
|
||||
{"false || false", false, "false-or"},
|
||||
{"false || null", nil, "false-or"},
|
||||
{"false || -10", -10, "false-or"},
|
||||
{"false || 0", 0, "false-or"},
|
||||
{"false || 10", 10, "false-or"},
|
||||
{"false || 3.14", 3.14, "false-or"},
|
||||
{"false || 0.0", 0, "false-or"},
|
||||
{"false || Infinity", math.Inf(1), "false-or"},
|
||||
// {"false || -Infinity", math.Inf(-1), "false-or"},
|
||||
{"false || NaN", math.NaN(), "false-or"},
|
||||
{"false || ''", "", "false-or"},
|
||||
{"false || 'abc'", "abc", "false-or"},
|
||||
// null &&
|
||||
{"null && true", nil, "null-and"},
|
||||
{"null && false", nil, "null-and"},
|
||||
{"null && null", nil, "null-and"},
|
||||
{"null && -10", nil, "null-and"},
|
||||
{"null && 0", nil, "null-and"},
|
||||
{"null && 10", nil, "null-and"},
|
||||
{"null && 3.14", nil, "null-and"},
|
||||
{"null && 0.0", nil, "null-and"},
|
||||
{"null && Infinity", nil, "null-and"},
|
||||
// {"null && -Infinity", nil, "null-and"},
|
||||
{"null && NaN", nil, "null-and"},
|
||||
{"null && ''", nil, "null-and"},
|
||||
{"null && 'abc'", nil, "null-and"},
|
||||
// null ||
|
||||
{"null || true", true, "null-or"},
|
||||
{"null || false", false, "null-or"},
|
||||
{"null || null", nil, "null-or"},
|
||||
{"null || -10", -10, "null-or"},
|
||||
{"null || 0", 0, "null-or"},
|
||||
{"null || 10", 10, "null-or"},
|
||||
{"null || 3.14", 3.14, "null-or"},
|
||||
{"null || 0.0", 0, "null-or"},
|
||||
{"null || Infinity", math.Inf(1), "null-or"},
|
||||
// {"null || -Infinity", math.Inf(-1), "null-or"},
|
||||
{"null || NaN", math.NaN(), "null-or"},
|
||||
{"null || ''", "", "null-or"},
|
||||
{"null || 'abc'", "abc", "null-or"},
|
||||
// -10 &&
|
||||
{"-10 && true", true, "neg-num-and"},
|
||||
{"-10 && false", false, "neg-num-and"},
|
||||
{"-10 && null", nil, "neg-num-and"},
|
||||
{"-10 && -10", -10, "neg-num-and"},
|
||||
{"-10 && 0", 0, "neg-num-and"},
|
||||
{"-10 && 10", 10, "neg-num-and"},
|
||||
{"-10 && 3.14", 3.14, "neg-num-and"},
|
||||
{"-10 && 0.0", 0, "neg-num-and"},
|
||||
{"-10 && Infinity", math.Inf(1), "neg-num-and"},
|
||||
// {"-10 && -Infinity", math.Inf(-1), "neg-num-and"},
|
||||
{"-10 && NaN", math.NaN(), "neg-num-and"},
|
||||
{"-10 && ''", "", "neg-num-and"},
|
||||
{"-10 && 'abc'", "abc", "neg-num-and"},
|
||||
// -10 ||
|
||||
{"-10 || true", -10, "neg-num-or"},
|
||||
{"-10 || false", -10, "neg-num-or"},
|
||||
{"-10 || null", -10, "neg-num-or"},
|
||||
{"-10 || -10", -10, "neg-num-or"},
|
||||
{"-10 || 0", -10, "neg-num-or"},
|
||||
{"-10 || 10", -10, "neg-num-or"},
|
||||
{"-10 || 3.14", -10, "neg-num-or"},
|
||||
{"-10 || 0.0", -10, "neg-num-or"},
|
||||
{"-10 || Infinity", -10, "neg-num-or"},
|
||||
// {"-10 || -Infinity", -10, "neg-num-or"},
|
||||
{"-10 || NaN", -10, "neg-num-or"},
|
||||
{"-10 || ''", -10, "neg-num-or"},
|
||||
{"-10 || 'abc'", -10, "neg-num-or"},
|
||||
// 0 &&
|
||||
{"0 && true", 0, "zero-and"},
|
||||
{"0 && false", 0, "zero-and"},
|
||||
{"0 && null", 0, "zero-and"},
|
||||
{"0 && -10", 0, "zero-and"},
|
||||
{"0 && 0", 0, "zero-and"},
|
||||
{"0 && 10", 0, "zero-and"},
|
||||
{"0 && 3.14", 0, "zero-and"},
|
||||
{"0 && 0.0", 0, "zero-and"},
|
||||
{"0 && Infinity", 0, "zero-and"},
|
||||
// {"0 && -Infinity", 0, "zero-and"},
|
||||
{"0 && NaN", 0, "zero-and"},
|
||||
{"0 && ''", 0, "zero-and"},
|
||||
{"0 && 'abc'", 0, "zero-and"},
|
||||
// 0 ||
|
||||
{"0 || true", true, "zero-or"},
|
||||
{"0 || false", false, "zero-or"},
|
||||
{"0 || null", nil, "zero-or"},
|
||||
{"0 || -10", -10, "zero-or"},
|
||||
{"0 || 0", 0, "zero-or"},
|
||||
{"0 || 10", 10, "zero-or"},
|
||||
{"0 || 3.14", 3.14, "zero-or"},
|
||||
{"0 || 0.0", 0, "zero-or"},
|
||||
{"0 || Infinity", math.Inf(1), "zero-or"},
|
||||
// {"0 || -Infinity", math.Inf(-1), "zero-or"},
|
||||
{"0 || NaN", math.NaN(), "zero-or"},
|
||||
{"0 || ''", "", "zero-or"},
|
||||
{"0 || 'abc'", "abc", "zero-or"},
|
||||
// 10 &&
|
||||
{"10 && true", true, "pos-num-and"},
|
||||
{"10 && false", false, "pos-num-and"},
|
||||
{"10 && null", nil, "pos-num-and"},
|
||||
{"10 && -10", -10, "pos-num-and"},
|
||||
{"10 && 0", 0, "pos-num-and"},
|
||||
{"10 && 10", 10, "pos-num-and"},
|
||||
{"10 && 3.14", 3.14, "pos-num-and"},
|
||||
{"10 && 0.0", 0, "pos-num-and"},
|
||||
{"10 && Infinity", math.Inf(1), "pos-num-and"},
|
||||
// {"10 && -Infinity", math.Inf(-1), "pos-num-and"},
|
||||
{"10 && NaN", math.NaN(), "pos-num-and"},
|
||||
{"10 && ''", "", "pos-num-and"},
|
||||
{"10 && 'abc'", "abc", "pos-num-and"},
|
||||
// 10 ||
|
||||
{"10 || true", 10, "pos-num-or"},
|
||||
{"10 || false", 10, "pos-num-or"},
|
||||
{"10 || null", 10, "pos-num-or"},
|
||||
{"10 || -10", 10, "pos-num-or"},
|
||||
{"10 || 0", 10, "pos-num-or"},
|
||||
{"10 || 10", 10, "pos-num-or"},
|
||||
{"10 || 3.14", 10, "pos-num-or"},
|
||||
{"10 || 0.0", 10, "pos-num-or"},
|
||||
{"10 || Infinity", 10, "pos-num-or"},
|
||||
// {"10 || -Infinity", 10, "pos-num-or"},
|
||||
{"10 || NaN", 10, "pos-num-or"},
|
||||
{"10 || ''", 10, "pos-num-or"},
|
||||
{"10 || 'abc'", 10, "pos-num-or"},
|
||||
// 3.14 &&
|
||||
{"3.14 && true", true, "pos-float-and"},
|
||||
{"3.14 && false", false, "pos-float-and"},
|
||||
{"3.14 && null", nil, "pos-float-and"},
|
||||
{"3.14 && -10", -10, "pos-float-and"},
|
||||
{"3.14 && 0", 0, "pos-float-and"},
|
||||
{"3.14 && 10", 10, "pos-float-and"},
|
||||
{"3.14 && 3.14", 3.14, "pos-float-and"},
|
||||
{"3.14 && 0.0", 0, "pos-float-and"},
|
||||
{"3.14 && Infinity", math.Inf(1), "pos-float-and"},
|
||||
// {"3.14 && -Infinity", math.Inf(-1), "pos-float-and"},
|
||||
{"3.14 && NaN", math.NaN(), "pos-float-and"},
|
||||
{"3.14 && ''", "", "pos-float-and"},
|
||||
{"3.14 && 'abc'", "abc", "pos-float-and"},
|
||||
// 3.14 ||
|
||||
{"3.14 || true", 3.14, "pos-float-or"},
|
||||
{"3.14 || false", 3.14, "pos-float-or"},
|
||||
{"3.14 || null", 3.14, "pos-float-or"},
|
||||
{"3.14 || -10", 3.14, "pos-float-or"},
|
||||
{"3.14 || 0", 3.14, "pos-float-or"},
|
||||
{"3.14 || 10", 3.14, "pos-float-or"},
|
||||
{"3.14 || 3.14", 3.14, "pos-float-or"},
|
||||
{"3.14 || 0.0", 3.14, "pos-float-or"},
|
||||
{"3.14 || Infinity", 3.14, "pos-float-or"},
|
||||
// {"3.14 || -Infinity", 3.14, "pos-float-or"},
|
||||
{"3.14 || NaN", 3.14, "pos-float-or"},
|
||||
{"3.14 || ''", 3.14, "pos-float-or"},
|
||||
{"3.14 || 'abc'", 3.14, "pos-float-or"},
|
||||
// Infinity &&
|
||||
{"Infinity && true", true, "pos-inf-and"},
|
||||
{"Infinity && false", false, "pos-inf-and"},
|
||||
{"Infinity && null", nil, "pos-inf-and"},
|
||||
{"Infinity && -10", -10, "pos-inf-and"},
|
||||
{"Infinity && 0", 0, "pos-inf-and"},
|
||||
{"Infinity && 10", 10, "pos-inf-and"},
|
||||
{"Infinity && 3.14", 3.14, "pos-inf-and"},
|
||||
{"Infinity && 0.0", 0, "pos-inf-and"},
|
||||
{"Infinity && Infinity", math.Inf(1), "pos-inf-and"},
|
||||
// {"Infinity && -Infinity", math.Inf(-1), "pos-inf-and"},
|
||||
{"Infinity && NaN", math.NaN(), "pos-inf-and"},
|
||||
{"Infinity && ''", "", "pos-inf-and"},
|
||||
{"Infinity && 'abc'", "abc", "pos-inf-and"},
|
||||
// Infinity ||
|
||||
{"Infinity || true", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || false", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || null", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || -10", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 0", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 10", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 3.14", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 0.0", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || Infinity", math.Inf(1), "pos-inf-or"},
|
||||
// {"Infinity || -Infinity", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || NaN", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || ''", math.Inf(1), "pos-inf-or"},
|
||||
{"Infinity || 'abc'", math.Inf(1), "pos-inf-or"},
|
||||
// -Infinity &&
|
||||
// {"-Infinity && true", true, "neg-inf-and"},
|
||||
// {"-Infinity && false", false, "neg-inf-and"},
|
||||
// {"-Infinity && null", nil, "neg-inf-and"},
|
||||
// {"-Infinity && -10", -10, "neg-inf-and"},
|
||||
// {"-Infinity && 0", 0, "neg-inf-and"},
|
||||
// {"-Infinity && 10", 10, "neg-inf-and"},
|
||||
// {"-Infinity && 3.14", 3.14, "neg-inf-and"},
|
||||
// {"-Infinity && 0.0", 0, "neg-inf-and"},
|
||||
// {"-Infinity && Infinity", math.Inf(1), "neg-inf-and"},
|
||||
// {"-Infinity && -Infinity", math.Inf(-1), "neg-inf-and"},
|
||||
// {"-Infinity && NaN", math.NaN(), "neg-inf-and"},
|
||||
// {"-Infinity && ''", "", "neg-inf-and"},
|
||||
// {"-Infinity && 'abc'", "abc", "neg-inf-and"},
|
||||
// -Infinity ||
|
||||
// {"-Infinity || true", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || false", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || null", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || -10", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 0", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 10", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 3.14", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 0.0", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || Infinity", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || -Infinity", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || NaN", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || ''", math.Inf(-1), "neg-inf-or"},
|
||||
// {"-Infinity || 'abc'", math.Inf(-1), "neg-inf-or"},
|
||||
// NaN &&
|
||||
{"NaN && true", math.NaN(), "nan-and"},
|
||||
{"NaN && false", math.NaN(), "nan-and"},
|
||||
{"NaN && null", math.NaN(), "nan-and"},
|
||||
{"NaN && -10", math.NaN(), "nan-and"},
|
||||
{"NaN && 0", math.NaN(), "nan-and"},
|
||||
{"NaN && 10", math.NaN(), "nan-and"},
|
||||
{"NaN && 3.14", math.NaN(), "nan-and"},
|
||||
{"NaN && 0.0", math.NaN(), "nan-and"},
|
||||
{"NaN && Infinity", math.NaN(), "nan-and"},
|
||||
// {"NaN && -Infinity", math.NaN(), "nan-and"},
|
||||
{"NaN && NaN", math.NaN(), "nan-and"},
|
||||
{"NaN && ''", math.NaN(), "nan-and"},
|
||||
{"NaN && 'abc'", math.NaN(), "nan-and"},
|
||||
// NaN ||
|
||||
{"NaN || true", true, "nan-or"},
|
||||
{"NaN || false", false, "nan-or"},
|
||||
{"NaN || null", nil, "nan-or"},
|
||||
{"NaN || -10", -10, "nan-or"},
|
||||
{"NaN || 0", 0, "nan-or"},
|
||||
{"NaN || 10", 10, "nan-or"},
|
||||
{"NaN || 3.14", 3.14, "nan-or"},
|
||||
{"NaN || 0.0", 0, "nan-or"},
|
||||
{"NaN || Infinity", math.Inf(1), "nan-or"},
|
||||
// {"NaN || -Infinity", math.Inf(-1), "nan-or"},
|
||||
{"NaN || NaN", math.NaN(), "nan-or"},
|
||||
{"NaN || ''", "", "nan-or"},
|
||||
{"NaN || 'abc'", "abc", "nan-or"},
|
||||
// "" &&
|
||||
{"'' && true", "", "empty-str-and"},
|
||||
{"'' && false", "", "empty-str-and"},
|
||||
{"'' && null", "", "empty-str-and"},
|
||||
{"'' && -10", "", "empty-str-and"},
|
||||
{"'' && 0", "", "empty-str-and"},
|
||||
{"'' && 10", "", "empty-str-and"},
|
||||
{"'' && 3.14", "", "empty-str-and"},
|
||||
{"'' && 0.0", "", "empty-str-and"},
|
||||
{"'' && Infinity", "", "empty-str-and"},
|
||||
// {"'' && -Infinity", "", "empty-str-and"},
|
||||
{"'' && NaN", "", "empty-str-and"},
|
||||
{"'' && ''", "", "empty-str-and"},
|
||||
{"'' && 'abc'", "", "empty-str-and"},
|
||||
// "" ||
|
||||
{"'' || true", true, "empty-str-or"},
|
||||
{"'' || false", false, "empty-str-or"},
|
||||
{"'' || null", nil, "empty-str-or"},
|
||||
{"'' || -10", -10, "empty-str-or"},
|
||||
{"'' || 0", 0, "empty-str-or"},
|
||||
{"'' || 10", 10, "empty-str-or"},
|
||||
{"'' || 3.14", 3.14, "empty-str-or"},
|
||||
{"'' || 0.0", 0, "empty-str-or"},
|
||||
{"'' || Infinity", math.Inf(1), "empty-str-or"},
|
||||
// {"'' || -Infinity", math.Inf(-1), "empty-str-or"},
|
||||
{"'' || NaN", math.NaN(), "empty-str-or"},
|
||||
{"'' || ''", "", "empty-str-or"},
|
||||
{"'' || 'abc'", "abc", "empty-str-or"},
|
||||
// "abc" &&
|
||||
{"'abc' && true", true, "str-and"},
|
||||
{"'abc' && false", false, "str-and"},
|
||||
{"'abc' && null", nil, "str-and"},
|
||||
{"'abc' && -10", -10, "str-and"},
|
||||
{"'abc' && 0", 0, "str-and"},
|
||||
{"'abc' && 10", 10, "str-and"},
|
||||
{"'abc' && 3.14", 3.14, "str-and"},
|
||||
{"'abc' && 0.0", 0, "str-and"},
|
||||
{"'abc' && Infinity", math.Inf(1), "str-and"},
|
||||
// {"'abc' && -Infinity", math.Inf(-1), "str-and"},
|
||||
{"'abc' && NaN", math.NaN(), "str-and"},
|
||||
{"'abc' && ''", "", "str-and"},
|
||||
{"'abc' && 'abc'", "abc", "str-and"},
|
||||
// "abc" ||
|
||||
{"'abc' || true", "abc", "str-or"},
|
||||
{"'abc' || false", "abc", "str-or"},
|
||||
{"'abc' || null", "abc", "str-or"},
|
||||
{"'abc' || -10", "abc", "str-or"},
|
||||
{"'abc' || 0", "abc", "str-or"},
|
||||
{"'abc' || 10", "abc", "str-or"},
|
||||
{"'abc' || 3.14", "abc", "str-or"},
|
||||
{"'abc' || 0.0", "abc", "str-or"},
|
||||
{"'abc' || Infinity", "abc", "str-or"},
|
||||
// {"'abc' || -Infinity", "abc", "str-or"},
|
||||
{"'abc' || NaN", "abc", "str-or"},
|
||||
{"'abc' || ''", "abc", "str-or"},
|
||||
{"'abc' || 'abc'", "abc", "str-or"},
|
||||
// extra tests
|
||||
{"0.0 && true", 0, "float-evaluation-0-alt"},
|
||||
{"-1.5 && true", true, "float-evaluation-neg-alt"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{
|
||||
Action: "push",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
|
||||
assert.True(t, math.IsNaN(output.(float64)))
|
||||
} else {
|
||||
assert.Equal(t, tt.expected, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContexts(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
expected any
|
||||
name string
|
||||
}{
|
||||
{"github.action", "push", "github-context"},
|
||||
{"github.event.commits[0].message", nil, "github-context-noexist-prop"},
|
||||
{"fromjson('{\"commits\":[]}').commits[0].message", nil, "github-context-noexist-prop"},
|
||||
{"github.event.pull_request.labels.*.name", nil, "github-context-noexist-prop"},
|
||||
{"env.TEST", "value", "env-context"},
|
||||
{"job.status", "success", "job-context"},
|
||||
{"steps.step-id.outputs.name", "value", "steps-context"},
|
||||
{"steps.step-id.conclusion", "success", "steps-context-conclusion"},
|
||||
{"steps.step-id.conclusion && true", true, "steps-context-conclusion"},
|
||||
{"steps.step-id2.conclusion", "skipped", "steps-context-conclusion"},
|
||||
{"steps.step-id2.conclusion && true", true, "steps-context-conclusion"},
|
||||
{"steps.step-id.outcome", "success", "steps-context-outcome"},
|
||||
{"steps.step-id['outcome']", "success", "steps-context-outcome"},
|
||||
{"steps.step-id.outcome == 'success'", true, "steps-context-outcome"},
|
||||
{"steps.step-id['outcome'] == 'success'", true, "steps-context-outcome"},
|
||||
{"steps.step-id.outcome && true", true, "steps-context-outcome"},
|
||||
{"steps['step-id']['outcome'] && true", true, "steps-context-outcome"},
|
||||
{"steps.step-id2.outcome", "failure", "steps-context-outcome"},
|
||||
{"steps.step-id2.outcome && true", true, "steps-context-outcome"},
|
||||
// Disabled, since the interpreter is still too broken
|
||||
// {"contains(steps.*.outcome, 'success')", true, "steps-context-array-outcome"},
|
||||
// {"contains(steps.*.outcome, 'failure')", true, "steps-context-array-outcome"},
|
||||
// {"contains(steps.*.outputs.name, 'value')", true, "steps-context-array-outputs"},
|
||||
{"runner.os", "Linux", "runner-context"},
|
||||
{"secrets.name", "value", "secrets-context"},
|
||||
{"vars.name", "value", "vars-context"},
|
||||
{"strategy.fail-fast", true, "strategy-context"},
|
||||
{"matrix.os", "Linux", "matrix-context"},
|
||||
{"needs.job-id.outputs.output-name", "value", "needs-context"},
|
||||
{"needs.job-id.result", "success", "needs-context"},
|
||||
{"inputs.name", "value", "inputs-context"},
|
||||
}
|
||||
|
||||
env := &EvaluationEnvironment{
|
||||
Github: &model.GithubContext{
|
||||
Action: "push",
|
||||
},
|
||||
Env: map[string]string{
|
||||
"TEST": "value",
|
||||
},
|
||||
Job: &model.JobContext{
|
||||
Status: "success",
|
||||
},
|
||||
Steps: map[string]*model.StepResult{
|
||||
"step-id": {
|
||||
Outputs: map[string]string{
|
||||
"name": "value",
|
||||
},
|
||||
},
|
||||
"step-id2": {
|
||||
Outcome: model.StepStatusFailure,
|
||||
Conclusion: model.StepStatusSkipped,
|
||||
},
|
||||
},
|
||||
Runner: map[string]any{
|
||||
"os": "Linux",
|
||||
"temp": "/tmp",
|
||||
"tool_cache": "/opt/hostedtoolcache",
|
||||
},
|
||||
Secrets: map[string]string{
|
||||
"name": "value",
|
||||
},
|
||||
Vars: map[string]string{
|
||||
"name": "value",
|
||||
},
|
||||
Strategy: map[string]any{
|
||||
"fail-fast": true,
|
||||
},
|
||||
Matrix: map[string]any{
|
||||
"os": "Linux",
|
||||
},
|
||||
Needs: map[string]Needs{
|
||||
"job-id": {
|
||||
Outputs: map[string]string{
|
||||
"output-name": "value",
|
||||
},
|
||||
Result: "success",
|
||||
},
|
||||
},
|
||||
Inputs: map[string]any{
|
||||
"name": "value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
1
act/exprparser/testdata/for-hashing-1.txt
vendored
1
act/exprparser/testdata/for-hashing-1.txt
vendored
@@ -1 +0,0 @@
|
||||
Hello
|
||||
1
act/exprparser/testdata/for-hashing-2.txt
vendored
1
act/exprparser/testdata/for-hashing-2.txt
vendored
@@ -1 +0,0 @@
|
||||
World!
|
||||
@@ -1 +0,0 @@
|
||||
Knock knock!
|
||||
@@ -1 +0,0 @@
|
||||
Anybody home?
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-billy/v5"
|
||||
"github.com/go-git/go-billy/v5/memfs"
|
||||
@@ -221,3 +222,63 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "target", resolved)
|
||||
}
|
||||
|
||||
func TestDefaultFsOpenReadlinkAndWalk(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating symlinks requires elevated privileges on Windows")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644))
|
||||
require.NoError(t, os.Symlink("file.txt", filepath.Join(root, "link.txt")))
|
||||
|
||||
fsys := &DefaultFs{}
|
||||
var walked []string
|
||||
require.NoError(t, fsys.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
require.NoError(t, err)
|
||||
walked = append(walked, info.Name())
|
||||
return nil
|
||||
}))
|
||||
require.Contains(t, walked, "file.txt")
|
||||
require.Contains(t, walked, "link.txt")
|
||||
|
||||
file, err := fsys.Open(filepath.Join(root, "file.txt"))
|
||||
require.NoError(t, err)
|
||||
data, err := io.ReadAll(file)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Close())
|
||||
require.Equal(t, "content", string(data))
|
||||
|
||||
link, err := fsys.Readlink(filepath.Join(root, "link.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "file.txt", link)
|
||||
}
|
||||
|
||||
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
|
||||
fc := &FileCollector{Fs: &memoryFs{Filesystem: memfs.New()}}
|
||||
walk := fc.CollectFiles(cancelledContext(t), nil)
|
||||
|
||||
err := walk("file", fakeFileInfo{name: "file"}, nil)
|
||||
require.EqualError(t, err, "copy cancelled")
|
||||
|
||||
err = walk("file", fakeFileInfo{name: "file"}, os.ErrPermission)
|
||||
require.ErrorIs(t, err, os.ErrPermission)
|
||||
}
|
||||
|
||||
func cancelledContext(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
|
||||
type fakeFileInfo struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (f fakeFileInfo) Name() string { return f.name }
|
||||
func (f fakeFileInfo) Size() int64 { return 0 }
|
||||
func (f fakeFileInfo) Mode() os.FileMode { return 0o644 }
|
||||
func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (f fakeFileInfo) IsDir() bool { return false }
|
||||
func (f fakeFileInfo) Sys() any { return nil }
|
||||
|
||||
138
act/ghcontext/github_context.go
Normal file
138
act/ghcontext/github_context.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package ghcontext fills a model.GithubContext from the local git checkout.
|
||||
// The pure data parts of the context live in the shared
|
||||
// gitea.dev/actions-proto-go/pkg/model package, only the helpers that need a
|
||||
// git repository on disk are kept here.
|
||||
package ghcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
|
||||
"gitea.dev/actions-proto-go/pkg/model"
|
||||
)
|
||||
|
||||
var (
|
||||
findGitRef = git.FindGitRef
|
||||
findGitRevision = git.FindGitRevision
|
||||
findGithubRepo = git.FindGithubRepo
|
||||
)
|
||||
|
||||
func withDefaultBranch(ctx context.Context, b string, event map[string]any) map[string]any {
|
||||
repoI, ok := event["repository"]
|
||||
if !ok {
|
||||
repoI = make(map[string]any)
|
||||
}
|
||||
|
||||
repo, ok := repoI.(map[string]any)
|
||||
if !ok {
|
||||
common.Logger(ctx).Warnf("unable to set default branch to %v", b)
|
||||
return event
|
||||
}
|
||||
|
||||
// if the branch is already there return with no changes
|
||||
if _, ok = repo["default_branch"]; ok {
|
||||
return event
|
||||
}
|
||||
|
||||
repo["default_branch"] = b
|
||||
event["repository"] = repo
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// SetRef resolves the ref of the context from its event payload, falling back
|
||||
// to the ref checked out in repoPath.
|
||||
func SetRef(ctx context.Context, ghc *model.GithubContext, defaultBranch, repoPath string) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||
switch ghc.EventName {
|
||||
case "pull_request_target":
|
||||
ghc.Ref = "refs/heads/" + ghc.BaseRef
|
||||
case "pull_request", "pull_request_review", "pull_request_review_comment":
|
||||
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
|
||||
case "deployment", "deployment_status":
|
||||
ghc.Ref = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "ref"))
|
||||
case "release":
|
||||
ghc.Ref = "refs/tags/" + model.AsString(model.NestedMapLookup(ghc.Event, "release", "tag_name"))
|
||||
case "push", "create", "workflow_dispatch":
|
||||
ghc.Ref = model.AsString(ghc.Event["ref"])
|
||||
default:
|
||||
defaultBranch := model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||
if defaultBranch != "" {
|
||||
ghc.Ref = "refs/heads/" + defaultBranch
|
||||
}
|
||||
}
|
||||
|
||||
if ghc.Ref == "" {
|
||||
ref, err := findGitRef(ctx, repoPath)
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git ref: %v", err)
|
||||
} else {
|
||||
logger.Debugf("using github ref: %s", ref)
|
||||
ghc.Ref = ref
|
||||
}
|
||||
|
||||
// set the branch in the event data
|
||||
if defaultBranch != "" {
|
||||
ghc.Event = withDefaultBranch(ctx, defaultBranch, ghc.Event)
|
||||
} else {
|
||||
ghc.Event = withDefaultBranch(ctx, "master", ghc.Event)
|
||||
}
|
||||
|
||||
if ghc.Ref == "" {
|
||||
ghc.Ref = "refs/heads/" + model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetSha resolves the commit of the context from its event payload, falling
|
||||
// back to the revision checked out in repoPath.
|
||||
func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||
switch ghc.EventName {
|
||||
case "pull_request_target":
|
||||
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
|
||||
case "deployment", "deployment_status":
|
||||
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "sha"))
|
||||
case "push", "create", "workflow_dispatch":
|
||||
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
|
||||
ghc.Sha = model.AsString(ghc.Event["after"])
|
||||
}
|
||||
}
|
||||
|
||||
if ghc.Sha == "" {
|
||||
_, sha, err := findGitRevision(ctx, repoPath)
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git revision: %v", err)
|
||||
} else {
|
||||
ghc.Sha = sha
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetRepositoryAndOwner resolves the repository of the context from the git
|
||||
// remote in repoPath when it is not set yet, and derives its owner.
|
||||
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, githubInstance, remoteName, repoPath string) {
|
||||
if ghc.Repository == "" {
|
||||
repo, err := findGithubRepo(ctx, repoPath, githubInstance, remoteName)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v; remoteName: %v, repoPath: %v): %v", githubInstance, remoteName, repoPath, err)
|
||||
return
|
||||
}
|
||||
ghc.Repository = repo
|
||||
}
|
||||
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
package ghcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/actions-proto-go/pkg/model"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -97,13 +98,13 @@ func TestSetRef(t *testing.T) {
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.eventName, func(t *testing.T) {
|
||||
ghc := &GithubContext{
|
||||
ghc := &model.GithubContext{
|
||||
EventName: table.eventName,
|
||||
BaseRef: "master",
|
||||
Event: table.event,
|
||||
}
|
||||
|
||||
ghc.SetRef(context.Background(), "main", "/some/dir")
|
||||
SetRef(context.Background(), ghc, "main", "/some/dir")
|
||||
ghc.SetRefTypeAndName()
|
||||
|
||||
assert.Equal(t, table.ref, ghc.Ref)
|
||||
@@ -116,12 +117,12 @@ func TestSetRef(t *testing.T) {
|
||||
return "", errors.New("no default branch")
|
||||
}
|
||||
|
||||
ghc := &GithubContext{
|
||||
ghc := &model.GithubContext{
|
||||
EventName: "no-default-branch",
|
||||
Event: map[string]any{},
|
||||
}
|
||||
|
||||
ghc.SetRef(context.Background(), "", "/some/dir")
|
||||
SetRef(context.Background(), ghc, "", "/some/dir")
|
||||
|
||||
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
||||
})
|
||||
@@ -202,13 +203,13 @@ func TestSetSha(t *testing.T) {
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.eventName, func(t *testing.T) {
|
||||
ghc := &GithubContext{
|
||||
ghc := &model.GithubContext{
|
||||
EventName: table.eventName,
|
||||
BaseRef: "master",
|
||||
Event: table.event,
|
||||
}
|
||||
|
||||
ghc.SetSha(context.Background(), "/some/dir")
|
||||
SetSha(context.Background(), ghc, "/some/dir")
|
||||
|
||||
assert.Equal(t, table.sha, ghc.Sha)
|
||||
})
|
||||
@@ -4,18 +4,6 @@
|
||||
|
||||
package lookpath
|
||||
|
||||
import "os"
|
||||
|
||||
type Env interface {
|
||||
Getenv(name string) string
|
||||
}
|
||||
|
||||
type defaultEnv struct{}
|
||||
|
||||
func (*defaultEnv) Getenv(name string) string {
|
||||
return os.Getenv(name)
|
||||
}
|
||||
|
||||
func LookPath(file string) (string, error) {
|
||||
return LookPath2(file, &defaultEnv{})
|
||||
}
|
||||
|
||||
74
act/lookpath/lp_unix_test.go
Normal file
74
act/lookpath/lp_unix_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||
|
||||
package lookpath
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testEnv map[string]string
|
||||
|
||||
func (e testEnv) Getenv(name string) string {
|
||||
return e[name]
|
||||
}
|
||||
|
||||
func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "tool")
|
||||
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := LookPath2("tool", testEnv{"PATH": string(filepath.ListSeparator) + dir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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, testEnv{"PATH": ""})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != exe {
|
||||
t.Fatalf("LookPath2() = %q, want %q", got, exe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "not-executable")
|
||||
if err := os.WriteFile(file, []byte("plain text"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := LookPath2(file, testEnv{"PATH": dir})
|
||||
var pathErr *Error
|
||||
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, fs.ErrPermission) {
|
||||
t.Fatalf("LookPath2(non-executable) error = %v, want fs.ErrPermission wrapped in *Error", err)
|
||||
}
|
||||
if pathErr.Error() != fs.ErrPermission.Error() {
|
||||
t.Fatalf("Error() = %q, want %q", pathErr.Error(), fs.ErrPermission.Error())
|
||||
}
|
||||
|
||||
_, err = LookPath2("missing", testEnv{"PATH": dir})
|
||||
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, ErrNotFound) {
|
||||
t.Fatalf("LookPath2(missing) error = %v, want ErrNotFound wrapped in *Error", err)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func LookPath2(file string, lenv Env) (string, error) {
|
||||
var exts []string
|
||||
x := lenv.Getenv(`PATHEXT`)
|
||||
if x != "" {
|
||||
for _, e := range strings.Split(strings.ToLower(x), `;`) {
|
||||
for e := range strings.SplitSeq(strings.ToLower(x), `;`) {
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// ActionRunsUsing is the type of runner for the action
|
||||
type ActionRunsUsing string
|
||||
|
||||
func (a *ActionRunsUsing) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
var using string
|
||||
if err := unmarshal(&using); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Force input to lowercase for case insensitive comparison
|
||||
format := ActionRunsUsing(strings.ToLower(using))
|
||||
switch format {
|
||||
case ActionRunsUsingNode24, ActionRunsUsingNode20, ActionRunsUsingNode16, ActionRunsUsingNode12, ActionRunsUsingDocker, ActionRunsUsingComposite, ActionRunsUsingGo:
|
||||
*a = format
|
||||
default:
|
||||
return fmt.Errorf("The runs.using key in action.yml must be one of: %v, got %s", []string{
|
||||
ActionRunsUsingComposite,
|
||||
ActionRunsUsingDocker,
|
||||
ActionRunsUsingNode12,
|
||||
ActionRunsUsingNode16,
|
||||
ActionRunsUsingNode20,
|
||||
ActionRunsUsingNode24,
|
||||
ActionRunsUsingGo,
|
||||
}, format)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
// ActionRunsUsingNode12 for running with node12
|
||||
ActionRunsUsingNode12 = "node12"
|
||||
// ActionRunsUsingNode16 for running with node16
|
||||
ActionRunsUsingNode16 = "node16"
|
||||
// ActionRunsUsingNode20 for running with node20
|
||||
ActionRunsUsingNode20 = "node20"
|
||||
// ActionRunsUsingNode24 for running with node24
|
||||
ActionRunsUsingNode24 = "node24"
|
||||
// ActionRunsUsingDocker for running with docker
|
||||
ActionRunsUsingDocker = "docker"
|
||||
// ActionRunsUsingComposite for running composite
|
||||
ActionRunsUsingComposite = "composite"
|
||||
// ActionRunsUsingGo for running with go
|
||||
ActionRunsUsingGo = "go"
|
||||
)
|
||||
|
||||
func (a ActionRunsUsing) IsNode() bool {
|
||||
switch a {
|
||||
case ActionRunsUsingNode12, ActionRunsUsingNode16, ActionRunsUsingNode20, ActionRunsUsingNode24:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a ActionRunsUsing) IsDocker() bool {
|
||||
return a == ActionRunsUsingDocker
|
||||
}
|
||||
|
||||
func (a ActionRunsUsing) IsComposite() bool {
|
||||
return a == ActionRunsUsingComposite
|
||||
}
|
||||
|
||||
// ActionRuns are a field in Action
|
||||
type ActionRuns struct {
|
||||
Using ActionRunsUsing `yaml:"using"`
|
||||
Env map[string]string `yaml:"env"`
|
||||
Main string `yaml:"main"`
|
||||
Pre string `yaml:"pre"`
|
||||
PreIf string `yaml:"pre-if"`
|
||||
Post string `yaml:"post"`
|
||||
PostIf string `yaml:"post-if"`
|
||||
Image string `yaml:"image"`
|
||||
Entrypoint string `yaml:"entrypoint"`
|
||||
Args []string `yaml:"args"`
|
||||
Steps []Step `yaml:"steps"`
|
||||
}
|
||||
|
||||
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.
|
||||
type Action struct {
|
||||
Name string `yaml:"name"`
|
||||
Author string `yaml:"author"`
|
||||
Description string `yaml:"description"`
|
||||
Inputs map[string]Input `yaml:"inputs"`
|
||||
Outputs map[string]Output `yaml:"outputs"`
|
||||
Runs ActionRuns `yaml:"runs"`
|
||||
Branding struct {
|
||||
Color string `yaml:"color"`
|
||||
Icon string `yaml:"icon"`
|
||||
} `yaml:"branding"`
|
||||
}
|
||||
|
||||
// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.
|
||||
type Input struct {
|
||||
Description string `yaml:"description"`
|
||||
Required bool `yaml:"required"`
|
||||
Default string `yaml:"default"`
|
||||
}
|
||||
|
||||
// Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions. For example, if you had an action that performed the addition of two inputs (x + y = z), the action could output the sum (z) for other actions to use as an input.
|
||||
type Output struct {
|
||||
Description string `yaml:"description"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
// ReadAction reads an action from a reader
|
||||
func ReadAction(in io.Reader) (*Action, error) {
|
||||
a := new(Action)
|
||||
err := yaml.NewDecoder(in).Decode(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// set defaults
|
||||
if a.Runs.PreIf == "" {
|
||||
a.Runs.PreIf = "always()"
|
||||
}
|
||||
if a.Runs.PostIf == "" {
|
||||
a.Runs.PostIf = "always()"
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
)
|
||||
|
||||
type GithubContext struct {
|
||||
Event map[string]any `json:"event"`
|
||||
EventPath string `json:"event_path"`
|
||||
Workflow string `json:"workflow"`
|
||||
RunID string `json:"run_id"`
|
||||
RunNumber string `json:"run_number"`
|
||||
Actor string `json:"actor"`
|
||||
Repository string `json:"repository"`
|
||||
EventName string `json:"event_name"`
|
||||
Sha string `json:"sha"`
|
||||
Ref string `json:"ref"`
|
||||
RefName string `json:"ref_name"`
|
||||
RefType string `json:"ref_type"`
|
||||
HeadRef string `json:"head_ref"`
|
||||
BaseRef string `json:"base_ref"`
|
||||
Token string `json:"token"`
|
||||
Workspace string `json:"workspace"`
|
||||
Action string `json:"action"`
|
||||
ActionPath string `json:"action_path"`
|
||||
ActionRef string `json:"action_ref"`
|
||||
ActionRepository string `json:"action_repository"`
|
||||
Job string `json:"job"`
|
||||
JobName string `json:"job_name"`
|
||||
RepositoryOwner string `json:"repository_owner"`
|
||||
RetentionDays string `json:"retention_days"`
|
||||
RunnerPerflog string `json:"runner_perflog"`
|
||||
RunnerTrackingID string `json:"runner_tracking_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
APIURL string `json:"api_url"`
|
||||
GraphQLURL string `json:"graphql_url"`
|
||||
|
||||
// For Gitea
|
||||
RunAttempt string `json:"run_attempt"`
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
} else if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nestedMapLookup(m map[string]any, ks ...string) (rval any) {
|
||||
var ok bool
|
||||
|
||||
if len(ks) == 0 { // degenerate input
|
||||
return nil
|
||||
}
|
||||
if rval, ok = m[ks[0]]; !ok {
|
||||
return nil
|
||||
} else if len(ks) == 1 { // we've reached the final key
|
||||
return rval
|
||||
} else if m, ok = rval.(map[string]any); !ok {
|
||||
return nil
|
||||
} else { // 1+ more keys
|
||||
return nestedMapLookup(m, ks[1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func withDefaultBranch(ctx context.Context, b string, event map[string]any) map[string]any {
|
||||
repoI, ok := event["repository"]
|
||||
if !ok {
|
||||
repoI = make(map[string]any)
|
||||
}
|
||||
|
||||
repo, ok := repoI.(map[string]any)
|
||||
if !ok {
|
||||
common.Logger(ctx).Warnf("unable to set default branch to %v", b)
|
||||
return event
|
||||
}
|
||||
|
||||
// if the branch is already there return with no changes
|
||||
if _, ok = repo["default_branch"]; ok {
|
||||
return event
|
||||
}
|
||||
|
||||
repo["default_branch"] = b
|
||||
event["repository"] = repo
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
var (
|
||||
findGitRef = git.FindGitRef
|
||||
findGitRevision = git.FindGitRevision
|
||||
)
|
||||
|
||||
func (ghc *GithubContext) SetRef(ctx context.Context, defaultBranch, repoPath string) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||
switch ghc.EventName {
|
||||
case "pull_request_target":
|
||||
ghc.Ref = "refs/heads/" + ghc.BaseRef
|
||||
case "pull_request", "pull_request_review", "pull_request_review_comment":
|
||||
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
|
||||
case "deployment", "deployment_status":
|
||||
ghc.Ref = asString(nestedMapLookup(ghc.Event, "deployment", "ref"))
|
||||
case "release":
|
||||
ghc.Ref = "refs/tags/" + asString(nestedMapLookup(ghc.Event, "release", "tag_name"))
|
||||
case "push", "create", "workflow_dispatch":
|
||||
ghc.Ref = asString(ghc.Event["ref"])
|
||||
default:
|
||||
defaultBranch := asString(nestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||
if defaultBranch != "" {
|
||||
ghc.Ref = "refs/heads/" + defaultBranch
|
||||
}
|
||||
}
|
||||
|
||||
if ghc.Ref == "" {
|
||||
ref, err := findGitRef(ctx, repoPath)
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git ref: %v", err)
|
||||
} else {
|
||||
logger.Debugf("using github ref: %s", ref)
|
||||
ghc.Ref = ref
|
||||
}
|
||||
|
||||
// set the branch in the event data
|
||||
if defaultBranch != "" {
|
||||
ghc.Event = withDefaultBranch(ctx, defaultBranch, ghc.Event)
|
||||
} else {
|
||||
ghc.Event = withDefaultBranch(ctx, "master", ghc.Event)
|
||||
}
|
||||
|
||||
if ghc.Ref == "" {
|
||||
ghc.Ref = "refs/heads/" + asString(nestedMapLookup(ghc.Event, "repository", "default_branch"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ghc *GithubContext) SetSha(ctx context.Context, repoPath string) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
||||
switch ghc.EventName {
|
||||
case "pull_request_target":
|
||||
ghc.Sha = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
|
||||
case "deployment", "deployment_status":
|
||||
ghc.Sha = asString(nestedMapLookup(ghc.Event, "deployment", "sha"))
|
||||
case "push", "create", "workflow_dispatch":
|
||||
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
|
||||
ghc.Sha = asString(ghc.Event["after"])
|
||||
}
|
||||
}
|
||||
|
||||
if ghc.Sha == "" {
|
||||
_, sha, err := findGitRevision(ctx, repoPath)
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git revision: %v", err)
|
||||
} else {
|
||||
ghc.Sha = sha
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ghc *GithubContext) SetRepositoryAndOwner(ctx context.Context, githubInstance, remoteName, repoPath string) {
|
||||
if ghc.Repository == "" {
|
||||
repo, err := git.FindGithubRepo(ctx, repoPath, githubInstance, remoteName)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v; remoteName: %v, repoPath: %v): %v", githubInstance, remoteName, repoPath, err)
|
||||
return
|
||||
}
|
||||
ghc.Repository = repo
|
||||
}
|
||||
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
|
||||
}
|
||||
|
||||
func (ghc *GithubContext) SetRefTypeAndName() {
|
||||
var refType, refName string
|
||||
|
||||
// https://docs.github.com/en/actions/learn-github-actions/environment-variables
|
||||
if strings.HasPrefix(ghc.Ref, "refs/tags/") {
|
||||
refType = "tag"
|
||||
refName = ghc.Ref[len("refs/tags/"):]
|
||||
} else if strings.HasPrefix(ghc.Ref, "refs/heads/") {
|
||||
refType = "branch"
|
||||
refName = ghc.Ref[len("refs/heads/"):]
|
||||
} else if strings.HasPrefix(ghc.Ref, "refs/pull/") {
|
||||
refType = ""
|
||||
refName = ghc.Ref[len("refs/pull/"):]
|
||||
}
|
||||
|
||||
if ghc.RefType == "" {
|
||||
ghc.RefType = refType
|
||||
}
|
||||
|
||||
if ghc.RefName == "" {
|
||||
ghc.RefName = refName
|
||||
}
|
||||
}
|
||||
|
||||
func (ghc *GithubContext) SetBaseAndHeadRef() {
|
||||
if ghc.EventName == "pull_request" || ghc.EventName == "pull_request_target" {
|
||||
if ghc.BaseRef == "" {
|
||||
ghc.BaseRef = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "ref"))
|
||||
}
|
||||
|
||||
if ghc.HeadRef == "" {
|
||||
ghc.HeadRef = asString(nestedMapLookup(ghc.Event, "pull_request", "head", "ref"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
|
||||
type JobContext struct {
|
||||
Status string `json:"status"`
|
||||
Container struct {
|
||||
ID string `json:"id"`
|
||||
Network string `json:"network"`
|
||||
} `json:"container"`
|
||||
Services map[string]struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"services"`
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// WorkflowPlanner contains methods for creating plans
|
||||
type WorkflowPlanner interface {
|
||||
PlanEvent(eventName string) (*Plan, error)
|
||||
PlanJob(jobName string) (*Plan, error)
|
||||
PlanAll() (*Plan, error)
|
||||
GetEvents() []string
|
||||
}
|
||||
|
||||
// Plan contains a list of stages to run in series
|
||||
type Plan struct {
|
||||
Stages []*Stage
|
||||
}
|
||||
|
||||
// Stage contains a list of runs to execute in parallel
|
||||
type Stage struct {
|
||||
Runs []*Run
|
||||
}
|
||||
|
||||
// Run represents a job from a workflow that needs to be run
|
||||
type Run struct {
|
||||
Workflow *Workflow
|
||||
JobID string
|
||||
}
|
||||
|
||||
func (r *Run) String() string {
|
||||
jobName := r.Job().Name
|
||||
if jobName == "" {
|
||||
jobName = r.JobID
|
||||
}
|
||||
return jobName
|
||||
}
|
||||
|
||||
// Job returns the job for this Run
|
||||
func (r *Run) Job() *Job {
|
||||
return r.Workflow.GetJob(r.JobID)
|
||||
}
|
||||
|
||||
type WorkflowFiles struct {
|
||||
workflowDirEntry os.DirEntry
|
||||
dirPath string
|
||||
}
|
||||
|
||||
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
|
||||
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
|
||||
path, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var workflows []WorkflowFiles
|
||||
|
||||
if fi.IsDir() {
|
||||
log.Debugf("Loading workflows from '%s'", path)
|
||||
if noWorkflowRecurse {
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, v := range files {
|
||||
workflows = append(workflows, WorkflowFiles{
|
||||
dirPath: path,
|
||||
workflowDirEntry: v,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
log.Debug("Loading workflows recursively")
|
||||
if err := filepath.Walk(path,
|
||||
func(p string, f os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !f.IsDir() {
|
||||
log.Debugf("Found workflow '%s' in '%s'", f.Name(), p)
|
||||
workflows = append(workflows, WorkflowFiles{
|
||||
dirPath: filepath.Dir(p),
|
||||
workflowDirEntry: fs.FileInfoToDirEntry(f),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Debugf("Loading workflow '%s'", path)
|
||||
dirname := filepath.Dir(path)
|
||||
|
||||
workflows = append(workflows, WorkflowFiles{
|
||||
dirPath: dirname,
|
||||
workflowDirEntry: fs.FileInfoToDirEntry(fi),
|
||||
})
|
||||
}
|
||||
|
||||
wp := new(workflowPlanner)
|
||||
for _, wf := range workflows {
|
||||
ext := filepath.Ext(wf.workflowDirEntry.Name())
|
||||
if ext == ".yml" || ext == ".yaml" {
|
||||
f, err := os.Open(filepath.Join(wf.dirPath, wf.workflowDirEntry.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("Reading workflow '%s'", f.Name())
|
||||
workflow, err := ReadWorkflow(f)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
if err == io.EOF {
|
||||
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", wf.workflowDirEntry.Name(), err)
|
||||
}
|
||||
return nil, fmt.Errorf("workflow is not valid. '%s': %w", wf.workflowDirEntry.Name(), err)
|
||||
}
|
||||
_, err = f.Seek(0, 0)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("error occurring when resetting io pointer in '%s': %w", wf.workflowDirEntry.Name(), err)
|
||||
}
|
||||
|
||||
workflow.File = wf.workflowDirEntry.Name()
|
||||
if workflow.Name == "" {
|
||||
workflow.Name = wf.workflowDirEntry.Name()
|
||||
}
|
||||
|
||||
err = validateJobName(workflow)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wp.workflows = append(wp.workflows, workflow)
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return wp, nil
|
||||
}
|
||||
|
||||
// CombineWorkflowPlanner combines workflows to a WorkflowPlanner
|
||||
func CombineWorkflowPlanner(workflows ...*Workflow) WorkflowPlanner {
|
||||
return &workflowPlanner{
|
||||
workflows: workflows,
|
||||
}
|
||||
}
|
||||
|
||||
func NewSingleWorkflowPlanner(name string, f io.Reader) (WorkflowPlanner, error) {
|
||||
wp := new(workflowPlanner)
|
||||
|
||||
log.Debugf("Reading workflow %s", name)
|
||||
workflow, err := ReadWorkflow(f)
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil, fmt.Errorf("unable to read workflow '%s': file is empty: %w", name, err)
|
||||
}
|
||||
return nil, fmt.Errorf("workflow is not valid. '%s': %w", name, err)
|
||||
}
|
||||
workflow.File = name
|
||||
if workflow.Name == "" {
|
||||
workflow.Name = name
|
||||
}
|
||||
|
||||
err = validateJobName(workflow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wp.workflows = append(wp.workflows, workflow)
|
||||
|
||||
return wp, nil
|
||||
}
|
||||
|
||||
func validateJobName(workflow *Workflow) error {
|
||||
jobNameRegex := regexp.MustCompile(`^([[:alpha:]_][[:alnum:]_\-]*)$`)
|
||||
for k := range workflow.Jobs {
|
||||
if ok := jobNameRegex.MatchString(k); !ok {
|
||||
return fmt.Errorf("workflow is not valid. '%s': Job name '%s' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", workflow.Name, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type workflowPlanner struct {
|
||||
workflows []*Workflow
|
||||
}
|
||||
|
||||
// PlanEvent builds a new list of runs to execute in parallel for an event name
|
||||
func (wp *workflowPlanner) PlanEvent(eventName string) (*Plan, error) {
|
||||
plan := new(Plan)
|
||||
if len(wp.workflows) == 0 {
|
||||
log.Debug("no workflows found by planner")
|
||||
return plan, nil
|
||||
}
|
||||
var lastErr error
|
||||
|
||||
for _, w := range wp.workflows {
|
||||
events := w.On()
|
||||
if len(events) == 0 {
|
||||
log.Debugf("no events found for workflow: %s", w.File)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, e := range events {
|
||||
if e == eventName {
|
||||
stages, err := createStages(w, w.GetJobIDs()...)
|
||||
if err != nil {
|
||||
log.Warn(err)
|
||||
lastErr = err
|
||||
} else {
|
||||
plan.mergeStages(stages)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return plan, lastErr
|
||||
}
|
||||
|
||||
// PlanJob builds a new run to execute in parallel for a job name
|
||||
func (wp *workflowPlanner) PlanJob(jobName string) (*Plan, error) {
|
||||
plan := new(Plan)
|
||||
if len(wp.workflows) == 0 {
|
||||
log.Debugf("no jobs found for workflow: %s", jobName)
|
||||
}
|
||||
var lastErr error
|
||||
|
||||
for _, w := range wp.workflows {
|
||||
stages, err := createStages(w, jobName)
|
||||
if err != nil {
|
||||
log.Warn(err)
|
||||
lastErr = err
|
||||
} else {
|
||||
plan.mergeStages(stages)
|
||||
}
|
||||
}
|
||||
return plan, lastErr
|
||||
}
|
||||
|
||||
// PlanAll builds a new run to execute in parallel all
|
||||
func (wp *workflowPlanner) PlanAll() (*Plan, error) {
|
||||
plan := new(Plan)
|
||||
if len(wp.workflows) == 0 {
|
||||
log.Debug("no workflows found by planner")
|
||||
return plan, nil
|
||||
}
|
||||
var lastErr error
|
||||
|
||||
for _, w := range wp.workflows {
|
||||
stages, err := createStages(w, w.GetJobIDs()...)
|
||||
if err != nil {
|
||||
log.Warn(err)
|
||||
lastErr = err
|
||||
} else {
|
||||
plan.mergeStages(stages)
|
||||
}
|
||||
}
|
||||
|
||||
return plan, lastErr
|
||||
}
|
||||
|
||||
// GetEvents gets all the events in the workflows file
|
||||
func (wp *workflowPlanner) GetEvents() []string {
|
||||
events := make([]string, 0)
|
||||
for _, w := range wp.workflows {
|
||||
found := false
|
||||
for _, e := range events {
|
||||
if slices.Contains(w.On(), e) {
|
||||
found = true
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
events = append(events, w.On()...)
|
||||
}
|
||||
}
|
||||
|
||||
// sort the list based on depth of dependencies
|
||||
slices.Sort(events)
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
// MaxRunNameLen determines the max name length of all jobs
|
||||
func (p *Plan) MaxRunNameLen() int {
|
||||
maxRunNameLen := 0
|
||||
for _, stage := range p.Stages {
|
||||
for _, run := range stage.Runs {
|
||||
runNameLen := len(run.String())
|
||||
if runNameLen > maxRunNameLen {
|
||||
maxRunNameLen = runNameLen
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxRunNameLen
|
||||
}
|
||||
|
||||
// GetJobIDs will get all the job names in the stage
|
||||
func (s *Stage) GetJobIDs() []string {
|
||||
names := make([]string, 0)
|
||||
for _, r := range s.Runs {
|
||||
names = append(names, r.JobID)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Merge stages with existing stages in plan
|
||||
func (p *Plan) mergeStages(stages []*Stage) {
|
||||
newStages := make([]*Stage, int(math.Max(float64(len(p.Stages)), float64(len(stages)))))
|
||||
for i := range newStages {
|
||||
newStages[i] = new(Stage)
|
||||
if i >= len(p.Stages) {
|
||||
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
|
||||
} else if i >= len(stages) {
|
||||
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
|
||||
} else {
|
||||
newStages[i].Runs = append(newStages[i].Runs, p.Stages[i].Runs...)
|
||||
newStages[i].Runs = append(newStages[i].Runs, stages[i].Runs...)
|
||||
}
|
||||
}
|
||||
p.Stages = newStages
|
||||
}
|
||||
|
||||
func createStages(w *Workflow, jobIDs ...string) ([]*Stage, error) {
|
||||
// first, build a list of all the necessary jobs to run, and their dependencies
|
||||
jobDependencies := make(map[string][]string)
|
||||
for len(jobIDs) > 0 {
|
||||
newJobIDs := make([]string, 0)
|
||||
for _, jID := range jobIDs {
|
||||
// make sure we haven't visited this job yet
|
||||
if _, ok := jobDependencies[jID]; !ok {
|
||||
if job := w.GetJob(jID); job != nil {
|
||||
jobDependencies[jID] = job.Needs()
|
||||
newJobIDs = append(newJobIDs, job.Needs()...)
|
||||
}
|
||||
}
|
||||
}
|
||||
jobIDs = newJobIDs
|
||||
}
|
||||
|
||||
// next, build an execution graph
|
||||
stages := make([]*Stage, 0)
|
||||
for len(jobDependencies) > 0 {
|
||||
stage := new(Stage)
|
||||
for jID, jDeps := range jobDependencies {
|
||||
// make sure all deps are in the graph already
|
||||
if listInStages(jDeps, stages...) {
|
||||
stage.Runs = append(stage.Runs, &Run{
|
||||
Workflow: w,
|
||||
JobID: jID,
|
||||
})
|
||||
delete(jobDependencies, jID)
|
||||
}
|
||||
}
|
||||
if len(stage.Runs) == 0 {
|
||||
return nil, fmt.Errorf("unable to build dependency graph for %s (%s)", w.Name, w.File)
|
||||
}
|
||||
stages = append(stages, stage)
|
||||
}
|
||||
|
||||
if len(stages) == 0 {
|
||||
return nil, errors.New("Could not find any stages to run. View the valid jobs with `act --list`. Use `act --help` to find how to filter by Job ID/Workflow/Event Name")
|
||||
}
|
||||
|
||||
return stages, nil
|
||||
}
|
||||
|
||||
// return true iff all strings in srcList exist in at least one of the stages
|
||||
func listInStages(srcList []string, stages ...*Stage) bool {
|
||||
for _, src := range srcList {
|
||||
found := false
|
||||
for _, stage := range stages {
|
||||
for _, search := range stage.GetJobIDs() {
|
||||
if src == search {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type WorkflowPlanTest struct {
|
||||
workflowPath string
|
||||
errorMessage string
|
||||
noWorkflowRecurse bool
|
||||
}
|
||||
|
||||
func TestPlanner(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
tables := []WorkflowPlanTest{
|
||||
{"invalid-job-name/invalid-1.yml", "workflow is not valid. 'invalid-job-name-1': Job name 'invalid-JOB-Name-v1.2.3-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
||||
{"invalid-job-name/invalid-2.yml", "workflow is not valid. 'invalid-job-name-2': Job name '1234invalid-JOB-Name-v123-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
||||
{"invalid-job-name/valid-1.yml", "", false},
|
||||
{"invalid-job-name/valid-2.yml", "", false},
|
||||
{"empty-workflow", "unable to read workflow 'push.yml': file is empty: EOF", false},
|
||||
{"nested", "unable to read workflow 'fail.yml': file is empty: EOF", false},
|
||||
{"nested", "", true},
|
||||
}
|
||||
|
||||
workdir, err := filepath.Abs("testdata")
|
||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
for _, table := range tables {
|
||||
fullWorkflowPath := filepath.Join(workdir, table.workflowPath)
|
||||
_, err = NewWorkflowPlanner(fullWorkflowPath, table.noWorkflowRecurse)
|
||||
if table.errorMessage == "" {
|
||||
assert.NoError(t, err, "WorkflowPlanner should exit without any error")
|
||||
} else {
|
||||
assert.EqualError(t, err, table.errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflow(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
workflow := Workflow{
|
||||
Jobs: map[string]*Job{
|
||||
"valid_job": {
|
||||
Name: "valid_job",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Check that an invalid job id returns error
|
||||
result, err := createStages(&workflow, "invalid_job_id")
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Nil(t, result)
|
||||
|
||||
// Check that an valid job id returns non-error
|
||||
result, err = createStages(&workflow, "valid_job")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
|
||||
import "fmt"
|
||||
|
||||
type stepStatus int
|
||||
|
||||
const (
|
||||
StepStatusSuccess stepStatus = iota
|
||||
StepStatusFailure
|
||||
StepStatusSkipped
|
||||
)
|
||||
|
||||
var stepStatusStrings = [...]string{
|
||||
"success",
|
||||
"failure",
|
||||
"skipped",
|
||||
}
|
||||
|
||||
func (s stepStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *stepStatus) UnmarshalText(b []byte) error {
|
||||
str := string(b)
|
||||
for i, name := range stepStatusStrings {
|
||||
if name == str {
|
||||
*s = stepStatus(i)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid step status %q", str)
|
||||
}
|
||||
|
||||
func (s stepStatus) String() string {
|
||||
if int(s) >= len(stepStatusStrings) {
|
||||
return ""
|
||||
}
|
||||
return stepStatusStrings[s]
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
Outputs map[string]string `json:"outputs"`
|
||||
Conclusion stepStatus `json:"conclusion"`
|
||||
Outcome stepStatus `json:"outcome"`
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
name: invalid-job-name-1
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
invalid-JOB-Name-v1.2.3-docker_hub:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
valid-JOB-Name-v123-docker_hub:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
@@ -1,8 +0,0 @@
|
||||
name: invalid-job-name-2
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
1234invalid-JOB-Name-v123-docker_hub:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
@@ -1,8 +0,0 @@
|
||||
name: valid-job-name-1
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
valid-JOB-Name-v123-docker_hub:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
@@ -1,8 +0,0 @@
|
||||
name: valid-job-name-2
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
___valid-JOB-Name-v123-docker_hub:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
9
act/model/testdata/nested/success.yml
vendored
9
act/model/testdata/nested/success.yml
vendored
@@ -1,9 +0,0 @@
|
||||
name: Hello World Workflow
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
hello-world:
|
||||
name: Hello World Job
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Hello World!"
|
||||
50
act/model/testdata/strategy/push.yml
vendored
50
act/model/testdata/strategy/push.yml
vendored
@@ -1,50 +0,0 @@
|
||||
---
|
||||
jobs:
|
||||
strategy-all:
|
||||
name: ${{ matrix.node-version }} | ${{ matrix.site }} | ${{ matrix.datacenter }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'Hello!'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
datacenter:
|
||||
- site-c
|
||||
- site-d
|
||||
exclude:
|
||||
- datacenter: site-d
|
||||
node-version: 14.x
|
||||
site: staging
|
||||
include:
|
||||
- php-version: 5.4
|
||||
- datacenter: site-a
|
||||
node-version: 10.x
|
||||
site: prod
|
||||
- datacenter: site-b
|
||||
node-version: 12.x
|
||||
site: dev
|
||||
node-version: [14.x, 16.x]
|
||||
site:
|
||||
- staging
|
||||
max-parallel: 2
|
||||
strategy-no-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'Hello!'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
strategy-only-fail-fast:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'Hello!'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
strategy-only-max-parallel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo 'Hello!'
|
||||
strategy:
|
||||
max-parallel: 2
|
||||
'on':
|
||||
push: null
|
||||
@@ -1,845 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// Workflow is the structure of the files in .github/workflows
|
||||
type Workflow struct {
|
||||
File string
|
||||
Name string `yaml:"name"`
|
||||
RawOn yaml.Node `yaml:"on"`
|
||||
Env map[string]string `yaml:"env"`
|
||||
Jobs map[string]*Job `yaml:"jobs"`
|
||||
Defaults Defaults `yaml:"defaults"`
|
||||
RawConcurrency *RawConcurrency `yaml:"concurrency"`
|
||||
RawPermissions yaml.Node `yaml:"permissions"`
|
||||
}
|
||||
|
||||
// On events for the workflow
|
||||
func (w *Workflow) On() []string {
|
||||
switch w.RawOn.Kind {
|
||||
case yaml.ScalarNode:
|
||||
var val string
|
||||
err := w.RawOn.Decode(&val)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return []string{val}
|
||||
case yaml.SequenceNode:
|
||||
var val []string
|
||||
err := w.RawOn.Decode(&val)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return val
|
||||
case yaml.MappingNode:
|
||||
var val map[string]any
|
||||
err := w.RawOn.Decode(&val)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var keys []string
|
||||
for k := range val {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Workflow) OnEvent(event string) any {
|
||||
if w.RawOn.Kind == yaml.MappingNode {
|
||||
var val map[string]any
|
||||
if !decodeNode(w.RawOn, &val) {
|
||||
return nil
|
||||
}
|
||||
return val[event]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Workflow) OnSchedule() []string {
|
||||
schedules := w.OnEvent("schedule")
|
||||
if schedules == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
switch val := schedules.(type) {
|
||||
case []any:
|
||||
allSchedules := []string{}
|
||||
for _, v := range val {
|
||||
for k, cron := range v.(map[string]any) {
|
||||
if k != "cron" {
|
||||
continue
|
||||
}
|
||||
allSchedules = append(allSchedules, cron.(string))
|
||||
}
|
||||
}
|
||||
return allSchedules
|
||||
default:
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
type WorkflowDispatchInput struct {
|
||||
Description string `yaml:"description"`
|
||||
Required bool `yaml:"required"`
|
||||
Default string `yaml:"default"`
|
||||
Type string `yaml:"type"`
|
||||
Options []string `yaml:"options"`
|
||||
}
|
||||
|
||||
type WorkflowDispatch struct {
|
||||
Inputs map[string]WorkflowDispatchInput `yaml:"inputs"`
|
||||
}
|
||||
|
||||
func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch {
|
||||
switch w.RawOn.Kind {
|
||||
case yaml.ScalarNode:
|
||||
var val string
|
||||
if !decodeNode(w.RawOn, &val) {
|
||||
return nil
|
||||
}
|
||||
if val == "workflow_dispatch" {
|
||||
return &WorkflowDispatch{}
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
var val []string
|
||||
if !decodeNode(w.RawOn, &val) {
|
||||
return nil
|
||||
}
|
||||
if slices.Contains(val, "workflow_dispatch") {
|
||||
return &WorkflowDispatch{}
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
var val map[string]yaml.Node
|
||||
if !decodeNode(w.RawOn, &val) {
|
||||
return nil
|
||||
}
|
||||
|
||||
n, found := val["workflow_dispatch"]
|
||||
var workflowDispatch WorkflowDispatch
|
||||
if found && decodeNode(n, &workflowDispatch) {
|
||||
return &workflowDispatch
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WorkflowCallInput struct {
|
||||
Description string `yaml:"description"`
|
||||
Required bool `yaml:"required"`
|
||||
Default string `yaml:"default"`
|
||||
Type string `yaml:"type"`
|
||||
}
|
||||
|
||||
type WorkflowCallOutput struct {
|
||||
Description string `yaml:"description"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
type WorkflowCall struct {
|
||||
Inputs map[string]WorkflowCallInput `yaml:"inputs"`
|
||||
Outputs map[string]WorkflowCallOutput `yaml:"outputs"`
|
||||
}
|
||||
|
||||
type WorkflowCallResult struct {
|
||||
Outputs map[string]string
|
||||
}
|
||||
|
||||
func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
|
||||
if w.RawOn.Kind != yaml.MappingNode {
|
||||
// The callers expect for "on: workflow_call" and "on: [ workflow_call ]" a non nil return value
|
||||
return &WorkflowCall{}
|
||||
}
|
||||
|
||||
var val map[string]yaml.Node
|
||||
if !decodeNode(w.RawOn, &val) {
|
||||
return &WorkflowCall{}
|
||||
}
|
||||
|
||||
var config WorkflowCall
|
||||
node := val["workflow_call"]
|
||||
if !decodeNode(node, &config) {
|
||||
return &WorkflowCall{}
|
||||
}
|
||||
|
||||
return &config
|
||||
}
|
||||
|
||||
// Job is the structure of one job in a workflow
|
||||
type Job struct {
|
||||
Name string `yaml:"name"`
|
||||
RawNeeds yaml.Node `yaml:"needs"`
|
||||
RawRunsOn yaml.Node `yaml:"runs-on"`
|
||||
Env yaml.Node `yaml:"env"`
|
||||
If yaml.Node `yaml:"if"`
|
||||
Steps []*Step `yaml:"steps"`
|
||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||
Services map[string]*ContainerSpec `yaml:"services"`
|
||||
Strategy *Strategy `yaml:"strategy"`
|
||||
RawContainer yaml.Node `yaml:"container"`
|
||||
Defaults Defaults `yaml:"defaults"`
|
||||
Outputs map[string]string `yaml:"outputs"`
|
||||
Uses string `yaml:"uses"`
|
||||
With map[string]any `yaml:"with"`
|
||||
RawSecrets yaml.Node `yaml:"secrets"`
|
||||
RawPermissions yaml.Node `yaml:"permissions"`
|
||||
Result string
|
||||
}
|
||||
|
||||
// Strategy for the job
|
||||
type Strategy struct {
|
||||
FailFast bool
|
||||
MaxParallel int
|
||||
FailFastString string `yaml:"fail-fast"`
|
||||
MaxParallelString string `yaml:"max-parallel"`
|
||||
RawMatrix yaml.Node `yaml:"matrix"`
|
||||
}
|
||||
|
||||
// Default settings that will apply to all steps in the job or workflow
|
||||
type Defaults struct {
|
||||
Run RunDefaults `yaml:"run"`
|
||||
}
|
||||
|
||||
// Defaults for all run steps in the job or workflow
|
||||
type RunDefaults struct {
|
||||
Shell string `yaml:"shell"`
|
||||
WorkingDirectory string `yaml:"working-directory"`
|
||||
}
|
||||
|
||||
// GetMaxParallel sets default and returns value for `max-parallel`
|
||||
func (s Strategy) GetMaxParallel() int {
|
||||
// MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines`
|
||||
// So I take the liberty to hardcode default limit to 4 and this is because:
|
||||
// 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735
|
||||
// 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45
|
||||
// 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO
|
||||
maxParallel := 4
|
||||
if s.MaxParallelString != "" {
|
||||
var err error
|
||||
if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil {
|
||||
log.Errorf("Failed to parse 'max-parallel' option: %v", err)
|
||||
}
|
||||
}
|
||||
return maxParallel
|
||||
}
|
||||
|
||||
// GetFailFast sets default and returns value for `fail-fast`
|
||||
func (s Strategy) GetFailFast() bool {
|
||||
// FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107
|
||||
failFast := true
|
||||
log.Debug(s.FailFastString)
|
||||
if s.FailFastString != "" {
|
||||
var err error
|
||||
if failFast, err = strconv.ParseBool(s.FailFastString); err != nil {
|
||||
log.Errorf("Failed to parse 'fail-fast' option: %v", err)
|
||||
}
|
||||
}
|
||||
return failFast
|
||||
}
|
||||
|
||||
func (j *Job) InheritSecrets() bool {
|
||||
if j.RawSecrets.Kind != yaml.ScalarNode {
|
||||
return false
|
||||
}
|
||||
|
||||
var val string
|
||||
if !decodeNode(j.RawSecrets, &val) {
|
||||
return false
|
||||
}
|
||||
|
||||
return val == "inherit"
|
||||
}
|
||||
|
||||
func (j *Job) Secrets() map[string]string {
|
||||
if j.RawSecrets.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
|
||||
var val map[string]string
|
||||
if !decodeNode(j.RawSecrets, &val) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// Container details for the job
|
||||
func (j *Job) Container() *ContainerSpec {
|
||||
var val *ContainerSpec
|
||||
switch j.RawContainer.Kind {
|
||||
case yaml.ScalarNode:
|
||||
val = new(ContainerSpec)
|
||||
if !decodeNode(j.RawContainer, &val.Image) {
|
||||
return nil
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
val = new(ContainerSpec)
|
||||
if !decodeNode(j.RawContainer, val) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// Needs list for Job
|
||||
func (j *Job) Needs() []string {
|
||||
switch j.RawNeeds.Kind {
|
||||
case yaml.ScalarNode:
|
||||
var val string
|
||||
if !decodeNode(j.RawNeeds, &val) {
|
||||
return nil
|
||||
}
|
||||
return []string{val}
|
||||
case yaml.SequenceNode:
|
||||
var val []string
|
||||
if !decodeNode(j.RawNeeds, &val) {
|
||||
return nil
|
||||
}
|
||||
return val
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunsOn list for Job
|
||||
func (j *Job) RunsOn() []string {
|
||||
switch j.RawRunsOn.Kind {
|
||||
case yaml.MappingNode:
|
||||
var val struct {
|
||||
Group string
|
||||
Labels yaml.Node
|
||||
}
|
||||
|
||||
if !decodeNode(j.RawRunsOn, &val) {
|
||||
return nil
|
||||
}
|
||||
|
||||
labels := nodeAsStringSlice(val.Labels)
|
||||
|
||||
if val.Group != "" {
|
||||
labels = append(labels, val.Group)
|
||||
}
|
||||
|
||||
return labels
|
||||
default:
|
||||
return nodeAsStringSlice(j.RawRunsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func nodeAsStringSlice(node yaml.Node) []string {
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
var val string
|
||||
if !decodeNode(node, &val) {
|
||||
return nil
|
||||
}
|
||||
return []string{val}
|
||||
case yaml.SequenceNode:
|
||||
var val []string
|
||||
if !decodeNode(node, &val) {
|
||||
return nil
|
||||
}
|
||||
return val
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func environment(yml yaml.Node) map[string]string {
|
||||
env := make(map[string]string)
|
||||
if yml.Kind == yaml.MappingNode {
|
||||
if !decodeNode(yml, &env) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// Environment returns string-based key=value map for a job
|
||||
func (j *Job) Environment() map[string]string {
|
||||
return environment(j.Env)
|
||||
}
|
||||
|
||||
// normalizeMatrixValue converts a matrix value to []interface{}.
|
||||
// Arrays pass through unchanged; scalars are wrapped in a single-element array.
|
||||
// Unevaluated template expressions are wrapped as a fallback — proper resolution
|
||||
// happens via EvaluateYamlNode before Matrix() is called. Nested maps are rejected.
|
||||
func normalizeMatrixValue(key string, val any) ([]any, error) {
|
||||
switch t := val.(type) {
|
||||
case []any:
|
||||
// Already an array - use as-is
|
||||
return t, nil
|
||||
case string, int, float64, bool, nil:
|
||||
// Valid scalar types that can appear in YAML
|
||||
// These can be unevaluated template expressions (strings) or literal values
|
||||
return []any{t}, nil
|
||||
case map[string]any:
|
||||
// Nested map indicates misconfiguration - likely user error
|
||||
return nil, fmt.Errorf("matrix key %q has invalid nested object value - expected scalar or array, got map", key)
|
||||
default:
|
||||
// Unknown types might indicate parsing issues
|
||||
log.Warnf("matrix key %q has unexpected type %T, wrapping as single value", key, t)
|
||||
return []any{t}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix decodes the RawMatrix YAML node into a map[string][]interface{}.
|
||||
// Scalar values are wrapped into single-element arrays automatically.
|
||||
// Template expressions are resolved by EvaluateYamlNode before this method is
|
||||
// called; if unresolved, the literal string is wrapped as a one-element fallback.
|
||||
func (j *Job) Matrix() map[string][]any {
|
||||
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode to flexible map first so that scalar values don't cause a type error.
|
||||
var flexVal map[string]any
|
||||
err := j.Strategy.RawMatrix.Decode(&flexVal)
|
||||
if err != nil {
|
||||
// Fall back to the strict array-only format for backward compatibility.
|
||||
var val map[string][]any
|
||||
if !decodeNode(j.Strategy.RawMatrix, &val) {
|
||||
return nil
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// Convert flexible format to expected format with validation
|
||||
val := make(map[string][]any)
|
||||
for k, v := range flexVal {
|
||||
normalized, err := normalizeMatrixValue(k, v)
|
||||
if err != nil {
|
||||
log.Errorf("matrix validation error: %v", err)
|
||||
return nil
|
||||
}
|
||||
val[k] = normalized
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// GetMatrixes returns the matrix cross product
|
||||
// It skips includes and hard fails excludes for non-existing keys
|
||||
func (j *Job) GetMatrixes() ([]map[string]any, error) {
|
||||
matrixes := make([]map[string]any, 0)
|
||||
if j.Strategy != nil {
|
||||
// Always set these values, even if there's an error later
|
||||
j.Strategy.FailFast = j.Strategy.GetFailFast()
|
||||
j.Strategy.MaxParallel = j.Strategy.GetMaxParallel()
|
||||
|
||||
if m := j.Matrix(); m != nil {
|
||||
includes := make([]map[string]any, 0)
|
||||
extraIncludes := make([]map[string]any, 0)
|
||||
for _, v := range m["include"] {
|
||||
switch t := v.(type) {
|
||||
case []any:
|
||||
for _, i := range t {
|
||||
i := i.(map[string]any)
|
||||
extraInclude := true
|
||||
for k := range i {
|
||||
if _, ok := m[k]; ok {
|
||||
includes = append(includes, i)
|
||||
extraInclude = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if extraInclude {
|
||||
extraIncludes = append(extraIncludes, i)
|
||||
}
|
||||
}
|
||||
case any:
|
||||
v := v.(map[string]any)
|
||||
extraInclude := true
|
||||
for k := range v {
|
||||
if _, ok := m[k]; ok {
|
||||
includes = append(includes, v)
|
||||
extraInclude = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if extraInclude {
|
||||
extraIncludes = append(extraIncludes, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(m, "include")
|
||||
|
||||
excludes := make([]map[string]any, 0)
|
||||
for _, e := range m["exclude"] {
|
||||
e := e.(map[string]any)
|
||||
for k := range e {
|
||||
if _, ok := m[k]; ok {
|
||||
excludes = append(excludes, e)
|
||||
} else {
|
||||
// We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include
|
||||
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(m, "exclude")
|
||||
|
||||
matrixProduct := common.CartesianProduct(m)
|
||||
MATRIX:
|
||||
for _, matrix := range matrixProduct {
|
||||
for _, exclude := range excludes {
|
||||
if commonKeysMatch(matrix, exclude) {
|
||||
log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude)
|
||||
continue MATRIX
|
||||
}
|
||||
}
|
||||
matrixes = append(matrixes, matrix)
|
||||
}
|
||||
for _, include := range includes {
|
||||
matched := false
|
||||
for _, matrix := range matrixes {
|
||||
if commonKeysMatch2(matrix, include, m) {
|
||||
matched = true
|
||||
log.Debugf("Adding include values '%v' to existing entry", include)
|
||||
maps.Copy(matrix, include)
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
extraIncludes = append(extraIncludes, include)
|
||||
}
|
||||
}
|
||||
for _, include := range extraIncludes {
|
||||
log.Debugf("Adding include '%v'", include)
|
||||
matrixes = append(matrixes, include)
|
||||
}
|
||||
if len(matrixes) == 0 {
|
||||
matrixes = append(matrixes, make(map[string]any))
|
||||
}
|
||||
} else {
|
||||
matrixes = append(matrixes, make(map[string]any))
|
||||
}
|
||||
} else {
|
||||
matrixes = append(matrixes, make(map[string]any))
|
||||
log.Debugf("Empty Strategy, matrixes=%v", matrixes)
|
||||
}
|
||||
return matrixes, nil
|
||||
}
|
||||
|
||||
func commonKeysMatch(a, b map[string]any) bool {
|
||||
for aKey, aVal := range a {
|
||||
if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func commonKeysMatch2(a, b map[string]any, m map[string][]any) bool {
|
||||
for aKey, aVal := range a {
|
||||
_, useKey := m[aKey]
|
||||
if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// JobType describes what type of job we are about to run
|
||||
type JobType int
|
||||
|
||||
const (
|
||||
// JobTypeDefault is all jobs that have a `run` attribute
|
||||
JobTypeDefault JobType = iota
|
||||
|
||||
// JobTypeReusableWorkflowLocal is all jobs that have a `uses` that is a local workflow in the .github/workflows directory
|
||||
JobTypeReusableWorkflowLocal
|
||||
|
||||
// JobTypeReusableWorkflowRemote is all jobs that have a `uses` that references a workflow file in a github repo
|
||||
JobTypeReusableWorkflowRemote
|
||||
|
||||
// JobTypeInvalid represents a job which is not configured correctly
|
||||
JobTypeInvalid
|
||||
)
|
||||
|
||||
func (j JobType) String() string {
|
||||
switch j {
|
||||
case JobTypeDefault:
|
||||
return "default"
|
||||
case JobTypeReusableWorkflowLocal:
|
||||
return "local-reusable-workflow"
|
||||
case JobTypeReusableWorkflowRemote:
|
||||
return "remote-reusable-workflow"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Type returns the type of the job
|
||||
func (j *Job) Type() (JobType, error) {
|
||||
isReusable := j.Uses != ""
|
||||
|
||||
if isReusable {
|
||||
isYaml, _ := regexp.MatchString(`\.(ya?ml)(?:$|@)`, j.Uses)
|
||||
|
||||
if isYaml {
|
||||
isLocalPath := strings.HasPrefix(j.Uses, "./")
|
||||
isRemotePath, _ := regexp.MatchString(`^[^.](.+?/){2,}.+\.ya?ml@`, j.Uses)
|
||||
hasVersion, _ := regexp.MatchString(`\.ya?ml@`, j.Uses)
|
||||
|
||||
if isLocalPath {
|
||||
return JobTypeReusableWorkflowLocal, nil
|
||||
} else if isRemotePath && hasVersion {
|
||||
return JobTypeReusableWorkflowRemote, nil
|
||||
}
|
||||
}
|
||||
|
||||
return JobTypeInvalid, fmt.Errorf("`uses` key references invalid workflow path '%s'. Must start with './' if it's a local workflow, or must start with '<org>/<repo>/' and include an '@' if it's a remote workflow", j.Uses)
|
||||
}
|
||||
|
||||
return JobTypeDefault, nil
|
||||
}
|
||||
|
||||
// ContainerSpec is the specification of the container to use for the job
|
||||
type ContainerSpec struct {
|
||||
Image string `yaml:"image"`
|
||||
Env map[string]string `yaml:"env"`
|
||||
Ports []string `yaml:"ports"`
|
||||
Volumes []string `yaml:"volumes"`
|
||||
Options string `yaml:"options"`
|
||||
Credentials map[string]string `yaml:"credentials"`
|
||||
Entrypoint string
|
||||
Args string
|
||||
Name string
|
||||
Reuse bool
|
||||
|
||||
// Gitea specific
|
||||
Cmd []string `yaml:"cmd"`
|
||||
}
|
||||
|
||||
// Step is the structure of one step in a job
|
||||
type Step struct {
|
||||
Number int `yaml:"-"`
|
||||
ID string `yaml:"id"`
|
||||
If yaml.Node `yaml:"if"`
|
||||
Name string `yaml:"name"`
|
||||
Uses string `yaml:"uses"`
|
||||
Run string `yaml:"run"`
|
||||
WorkingDirectory string `yaml:"working-directory"`
|
||||
Shell string `yaml:"shell"`
|
||||
Env yaml.Node `yaml:"env"`
|
||||
With map[string]string `yaml:"with"`
|
||||
RawContinueOnError string `yaml:"continue-on-error"`
|
||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||
}
|
||||
|
||||
// String gets the name of step
|
||||
func (s *Step) String() string {
|
||||
if s.Name != "" {
|
||||
return s.Name
|
||||
} else if s.Uses != "" {
|
||||
return s.Uses
|
||||
} else if s.Run != "" {
|
||||
return s.Run
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
// Environment returns string-based key=value map for a step
|
||||
func (s *Step) Environment() map[string]string {
|
||||
return environment(s.Env)
|
||||
}
|
||||
|
||||
// GetEnv gets the env for a step
|
||||
func (s *Step) GetEnv() map[string]string {
|
||||
env := s.Environment()
|
||||
|
||||
for k, v := range s.With {
|
||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(k), "_")
|
||||
envKey = "INPUT_" + strings.ToUpper(envKey)
|
||||
env[envKey] = v
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// ShellCommand returns the command for the shell
|
||||
func (s *Step) ShellCommand() string {
|
||||
var shellCommand string
|
||||
|
||||
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17
|
||||
switch s.Shell {
|
||||
case "", "bash":
|
||||
shellCommand = "bash --noprofile --norc -e -o pipefail {0}"
|
||||
case "pwsh":
|
||||
shellCommand = "pwsh -command . '{0}'"
|
||||
case "python":
|
||||
shellCommand = "python {0}"
|
||||
case "sh":
|
||||
shellCommand = "sh -e {0}"
|
||||
case "cmd":
|
||||
shellCommand = "cmd /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\""
|
||||
case "powershell":
|
||||
shellCommand = "powershell -command . '{0}'"
|
||||
default:
|
||||
shellCommand = s.Shell
|
||||
}
|
||||
return shellCommand
|
||||
}
|
||||
|
||||
// StepType describes what type of step we are about to run
|
||||
type StepType int
|
||||
|
||||
const (
|
||||
// StepTypeRun is all steps that have a `run` attribute
|
||||
StepTypeRun StepType = iota
|
||||
|
||||
// StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...`
|
||||
StepTypeUsesDockerURL
|
||||
|
||||
// StepTypeUsesActionLocal is all steps that have a `uses` that is a local action in a subdirectory
|
||||
StepTypeUsesActionLocal
|
||||
|
||||
// StepTypeUsesActionRemote is all steps that have a `uses` that is a reference to a github repo
|
||||
StepTypeUsesActionRemote
|
||||
|
||||
// StepTypeReusableWorkflowLocal is all steps that have a `uses` that is a local workflow in the .github/workflows directory
|
||||
StepTypeReusableWorkflowLocal
|
||||
|
||||
// StepTypeReusableWorkflowRemote is all steps that have a `uses` that references a workflow file in a github repo
|
||||
StepTypeReusableWorkflowRemote
|
||||
|
||||
// StepTypeInvalid is for steps that have invalid step action
|
||||
StepTypeInvalid
|
||||
)
|
||||
|
||||
func (s StepType) String() string {
|
||||
switch s {
|
||||
case StepTypeInvalid:
|
||||
return "invalid"
|
||||
case StepTypeRun:
|
||||
return "run"
|
||||
case StepTypeUsesActionLocal:
|
||||
return "local-action"
|
||||
case StepTypeUsesActionRemote:
|
||||
return "remote-action"
|
||||
case StepTypeUsesDockerURL:
|
||||
return "docker"
|
||||
case StepTypeReusableWorkflowLocal:
|
||||
return "local-reusable-workflow"
|
||||
case StepTypeReusableWorkflowRemote:
|
||||
return "remote-reusable-workflow"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Type returns the type of the step
|
||||
func (s *Step) Type() StepType {
|
||||
if s.Run == "" && s.Uses == "" {
|
||||
return StepTypeInvalid
|
||||
}
|
||||
|
||||
if s.Run != "" {
|
||||
if s.Uses != "" {
|
||||
return StepTypeInvalid
|
||||
}
|
||||
return StepTypeRun
|
||||
} else if strings.HasPrefix(s.Uses, "docker://") {
|
||||
return StepTypeUsesDockerURL
|
||||
} else if strings.HasPrefix(s.Uses, "./.github/workflows") && (strings.HasSuffix(s.Uses, ".yml") || strings.HasSuffix(s.Uses, ".yaml")) {
|
||||
return StepTypeReusableWorkflowLocal
|
||||
} else if !strings.HasPrefix(s.Uses, "./") && strings.Contains(s.Uses, ".github/workflows") && (strings.Contains(s.Uses, ".yml@") || strings.Contains(s.Uses, ".yaml@")) {
|
||||
return StepTypeReusableWorkflowRemote
|
||||
} else if strings.HasPrefix(s.Uses, "./") {
|
||||
return StepTypeUsesActionLocal
|
||||
}
|
||||
return StepTypeUsesActionRemote
|
||||
}
|
||||
|
||||
// UsesHash returns a hash of the uses string.
|
||||
// For Gitea.
|
||||
func (s *Step) UsesHash() string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Uses)))
|
||||
}
|
||||
|
||||
// ReadWorkflow returns a list of jobs for a given workflow file reader
|
||||
func ReadWorkflow(in io.Reader) (*Workflow, error) {
|
||||
w := new(Workflow)
|
||||
err := yaml.NewDecoder(in).Decode(w)
|
||||
return w, err
|
||||
}
|
||||
|
||||
// GetJob will get a job by name in the workflow
|
||||
func (w *Workflow) GetJob(jobID string) *Job {
|
||||
for id, j := range w.Jobs {
|
||||
if jobID == id {
|
||||
if j.Name == "" {
|
||||
j.Name = id
|
||||
}
|
||||
if j.If.Value == "" {
|
||||
j.If.Value = "success()"
|
||||
}
|
||||
return j
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJobIDs will get all the job names in the workflow
|
||||
func (w *Workflow) GetJobIDs() []string {
|
||||
ids := make([]string, 0)
|
||||
for id := range w.Jobs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
var OnDecodeNodeError = func(node yaml.Node, out any, err error) {
|
||||
log.Fatalf("Failed to decode node %v into %T: %v", node, out, err)
|
||||
}
|
||||
|
||||
func decodeNode(node yaml.Node, out any) bool {
|
||||
if err := node.Decode(out); err != nil {
|
||||
if OnDecodeNodeError != nil {
|
||||
OnDecodeNodeError(node, out, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// RawConcurrency represents a workflow concurrency or a job concurrency with uninterpolated options
|
||||
type RawConcurrency struct {
|
||||
Group string `yaml:"group,omitempty"`
|
||||
CancelInProgress string `yaml:"cancel-in-progress,omitempty"`
|
||||
RawExpression string `yaml:"-,omitempty"`
|
||||
}
|
||||
|
||||
type objectConcurrency RawConcurrency
|
||||
|
||||
func (r *RawConcurrency) UnmarshalYAML(n *yaml.Node) error {
|
||||
if err := n.Decode(&r.RawExpression); err == nil {
|
||||
return nil
|
||||
}
|
||||
return n.Decode((*objectConcurrency)(r))
|
||||
}
|
||||
|
||||
func (r *RawConcurrency) MarshalYAML() (any, error) {
|
||||
if r.RawExpression != "" {
|
||||
return r.RawExpression, nil
|
||||
}
|
||||
|
||||
return (*objectConcurrency)(r), nil
|
||||
}
|
||||
@@ -1,908 +0,0 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 5 * * 1,3'
|
||||
- cron: '30 5 * * 2,4'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
schedules := workflow.OnEvent("schedule")
|
||||
assert.Len(t, schedules, 2)
|
||||
|
||||
newSchedules := workflow.OnSchedule()
|
||||
assert.Len(t, newSchedules, 2)
|
||||
|
||||
assert.Equal(t, "30 5 * * 1,3", newSchedules[0])
|
||||
assert.Equal(t, "30 5 * * 2,4", newSchedules[1])
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
schedule:
|
||||
test: '30 5 * * 1,3'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
`
|
||||
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
newSchedules = workflow.OnSchedule()
|
||||
assert.Empty(t, newSchedules)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
schedule:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
`
|
||||
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
newSchedules = workflow.OnSchedule()
|
||||
assert.Empty(t, newSchedules)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on: [push, tag]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
`
|
||||
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
newSchedules = workflow.OnSchedule()
|
||||
assert.Empty(t, newSchedules)
|
||||
}
|
||||
|
||||
func TestReadWorkflow_StringEvent(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Len(t, workflow.On(), 1)
|
||||
assert.Contains(t, workflow.On(), "push")
|
||||
}
|
||||
|
||||
func TestReadWorkflow_ListEvent(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Len(t, workflow.On(), 2)
|
||||
assert.Contains(t, workflow.On(), "push")
|
||||
assert.Contains(t, workflow.On(), "pull_request")
|
||||
}
|
||||
|
||||
func TestReadWorkflow_MapEvent(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Len(t, workflow.On(), 2)
|
||||
assert.Contains(t, workflow.On(), "push")
|
||||
assert.Contains(t, workflow.On(), "pull_request")
|
||||
}
|
||||
|
||||
func TestReadWorkflow_RunsOnLabels(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
|
||||
jobs:
|
||||
test:
|
||||
container: nginx:latest
|
||||
runs-on:
|
||||
labels: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, workflow.Jobs["test"].RunsOn(), []string{"ubuntu-latest"}) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
func TestReadWorkflow_RunsOnLabelsWithGroup(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
|
||||
jobs:
|
||||
test:
|
||||
container: nginx:latest
|
||||
runs-on:
|
||||
labels: [ubuntu-latest]
|
||||
group: linux
|
||||
steps:
|
||||
- uses: ./actions/docker-url`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, workflow.Jobs["test"].RunsOn(), []string{"ubuntu-latest", "linux"}) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
func TestReadWorkflow_StringContainer(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
|
||||
jobs:
|
||||
test:
|
||||
container: nginx:latest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
test2:
|
||||
container:
|
||||
image: nginx:latest
|
||||
env:
|
||||
foo: bar
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Len(t, workflow.Jobs, 2)
|
||||
assert.Contains(t, workflow.Jobs["test"].Container().Image, "nginx:latest")
|
||||
assert.Contains(t, workflow.Jobs["test2"].Container().Image, "nginx:latest")
|
||||
assert.Contains(t, workflow.Jobs["test2"].Container().Env["foo"], "bar")
|
||||
}
|
||||
|
||||
func TestReadWorkflow_ObjectContainer(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
|
||||
jobs:
|
||||
test:
|
||||
container:
|
||||
image: r.example.org/something:latest
|
||||
credentials:
|
||||
username: registry-username
|
||||
password: registry-password
|
||||
env:
|
||||
HOME: /home/user
|
||||
volumes:
|
||||
- my_docker_volume:/volume_mount
|
||||
- /data/my_data
|
||||
- /source/directory:/destination/directory
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ./actions/docker-url
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Len(t, workflow.Jobs, 1)
|
||||
|
||||
container := workflow.GetJob("test").Container()
|
||||
|
||||
assert.Contains(t, container.Image, "r.example.org/something:latest")
|
||||
assert.Contains(t, container.Env["HOME"], "/home/user")
|
||||
assert.Contains(t, container.Credentials["username"], "registry-username")
|
||||
assert.Contains(t, container.Credentials["password"], "registry-password")
|
||||
assert.ElementsMatch(t, container.Volumes, []string{
|
||||
"my_docker_volume:/volume_mount",
|
||||
"/data/my_data",
|
||||
"/source/directory:/destination/directory",
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadWorkflow_JobTypes(t *testing.T) {
|
||||
yaml := `
|
||||
name: invalid job definition
|
||||
|
||||
jobs:
|
||||
default-job:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
remote-reusable-workflow-yml:
|
||||
uses: remote/repo/some/path/to/workflow.yml@main
|
||||
remote-reusable-workflow-yaml:
|
||||
uses: remote/repo/some/path/to/workflow.yaml@main
|
||||
remote-reusable-workflow-custom-path:
|
||||
uses: remote/repo/path/to/workflow.yml@main
|
||||
local-reusable-workflow-yml:
|
||||
uses: ./some/path/to/workflow.yml
|
||||
local-reusable-workflow-yaml:
|
||||
uses: ./some/path/to/workflow.yaml
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Len(t, workflow.Jobs, 6)
|
||||
|
||||
jobType, err := workflow.Jobs["default-job"].Type()
|
||||
assert.Equal(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, JobTypeDefault, jobType)
|
||||
|
||||
jobType, err = workflow.Jobs["remote-reusable-workflow-yml"].Type()
|
||||
assert.Equal(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, JobTypeReusableWorkflowRemote, jobType)
|
||||
|
||||
jobType, err = workflow.Jobs["remote-reusable-workflow-yaml"].Type()
|
||||
assert.Equal(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, JobTypeReusableWorkflowRemote, jobType)
|
||||
|
||||
jobType, err = workflow.Jobs["remote-reusable-workflow-custom-path"].Type()
|
||||
assert.Equal(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, JobTypeReusableWorkflowRemote, jobType)
|
||||
|
||||
jobType, err = workflow.Jobs["local-reusable-workflow-yml"].Type()
|
||||
assert.Equal(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, JobTypeReusableWorkflowLocal, jobType)
|
||||
|
||||
jobType, err = workflow.Jobs["local-reusable-workflow-yaml"].Type()
|
||||
assert.Equal(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, JobTypeReusableWorkflowLocal, jobType)
|
||||
}
|
||||
|
||||
func TestReadWorkflow_JobTypes_InvalidPath(t *testing.T) {
|
||||
yaml := `
|
||||
name: invalid job definition
|
||||
|
||||
jobs:
|
||||
remote-reusable-workflow-missing-version:
|
||||
uses: remote/repo/some/path/to/workflow.yml
|
||||
remote-reusable-workflow-bad-extension:
|
||||
uses: remote/repo/some/path/to/workflow.json
|
||||
local-reusable-workflow-bad-extension:
|
||||
uses: ./some/path/to/workflow.json
|
||||
local-reusable-workflow-bad-path:
|
||||
uses: some/path/to/workflow.yaml
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Len(t, workflow.Jobs, 4)
|
||||
|
||||
jobType, err := workflow.Jobs["remote-reusable-workflow-missing-version"].Type()
|
||||
assert.Equal(t, JobTypeInvalid, jobType)
|
||||
assert.NotEqual(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
jobType, err = workflow.Jobs["remote-reusable-workflow-bad-extension"].Type()
|
||||
assert.Equal(t, JobTypeInvalid, jobType)
|
||||
assert.NotEqual(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
jobType, err = workflow.Jobs["local-reusable-workflow-bad-extension"].Type()
|
||||
assert.Equal(t, JobTypeInvalid, jobType)
|
||||
assert.NotEqual(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
jobType, err = workflow.Jobs["local-reusable-workflow-bad-path"].Type()
|
||||
assert.Equal(t, JobTypeInvalid, jobType)
|
||||
assert.NotEqual(t, nil, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
func TestReadWorkflow_StepsTypes(t *testing.T) {
|
||||
yaml := `
|
||||
name: invalid step definition
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: test1
|
||||
uses: actions/checkout@v2
|
||||
run: echo
|
||||
- name: test2
|
||||
run: echo
|
||||
- name: test3
|
||||
uses: actions/checkout@v2
|
||||
- name: test4
|
||||
uses: docker://nginx:latest
|
||||
- name: test5
|
||||
uses: ./local-action
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Len(t, workflow.Jobs, 1)
|
||||
assert.Len(t, workflow.Jobs["test"].Steps, 5)
|
||||
assert.Equal(t, workflow.Jobs["test"].Steps[0].Type(), StepTypeInvalid) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, workflow.Jobs["test"].Steps[1].Type(), StepTypeRun) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, workflow.Jobs["test"].Steps[2].Type(), StepTypeUsesActionRemote) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, workflow.Jobs["test"].Steps[3].Type(), StepTypeUsesDockerURL) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, workflow.Jobs["test"].Steps[4].Type(), StepTypeUsesActionLocal) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
// See: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idoutputs
|
||||
func TestReadWorkflow_JobOutputs(t *testing.T) {
|
||||
yaml := `
|
||||
name: job outputs definition
|
||||
|
||||
jobs:
|
||||
test1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: test1_1
|
||||
run: |
|
||||
echo "::set-output name=a_key::some-a_value"
|
||||
echo "::set-output name=b-key::some-b-value"
|
||||
outputs:
|
||||
some_a_key: ${{ steps.test1_1.outputs.a_key }}
|
||||
some-b-key: ${{ steps.test1_1.outputs.b-key }}
|
||||
|
||||
test2:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test1
|
||||
steps:
|
||||
- name: test2_1
|
||||
run: |
|
||||
echo "${{ needs.test1.outputs.some_a_key }}"
|
||||
echo "${{ needs.test1.outputs.some-b-key }}"
|
||||
`
|
||||
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Len(t, workflow.Jobs, 2)
|
||||
|
||||
assert.Len(t, workflow.Jobs["test1"].Steps, 1)
|
||||
assert.Equal(t, StepTypeRun, workflow.Jobs["test1"].Steps[0].Type())
|
||||
assert.Equal(t, "test1_1", workflow.Jobs["test1"].Steps[0].ID)
|
||||
assert.Len(t, workflow.Jobs["test1"].Outputs, 2)
|
||||
assert.Contains(t, workflow.Jobs["test1"].Outputs, "some_a_key")
|
||||
assert.Contains(t, workflow.Jobs["test1"].Outputs, "some-b-key")
|
||||
assert.Equal(t, "${{ steps.test1_1.outputs.a_key }}", workflow.Jobs["test1"].Outputs["some_a_key"])
|
||||
assert.Equal(t, "${{ steps.test1_1.outputs.b-key }}", workflow.Jobs["test1"].Outputs["some-b-key"])
|
||||
}
|
||||
|
||||
func TestReadWorkflow_Strategy(t *testing.T) {
|
||||
w, err := NewWorkflowPlanner("testdata/strategy/push.yml", true)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
p, err := w.PlanJob("strategy-only-max-parallel")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
assert.Equal(t, len(p.Stages), 1) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, len(p.Stages[0].Runs), 1) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
wf := p.Stages[0].Runs[0].Workflow
|
||||
|
||||
job := wf.Jobs["strategy-only-max-parallel"]
|
||||
matrixes, err := job.GetMatrixes()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, matrixes, []map[string]any{{}}) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Matrix(), map[string][]any(nil))
|
||||
assert.Equal(t, job.Strategy.MaxParallel, 2) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Strategy.FailFast, true) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
job = wf.Jobs["strategy-only-fail-fast"]
|
||||
matrixes, err = job.GetMatrixes()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, matrixes, []map[string]any{{}}) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Matrix(), map[string][]any(nil))
|
||||
assert.Equal(t, job.Strategy.MaxParallel, 4) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Strategy.FailFast, false) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
job = wf.Jobs["strategy-no-matrix"]
|
||||
matrixes, err = job.GetMatrixes()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, matrixes, []map[string]any{{}}) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Matrix(), map[string][]any(nil))
|
||||
assert.Equal(t, job.Strategy.MaxParallel, 2) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Strategy.FailFast, false) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
job = wf.Jobs["strategy-all"]
|
||||
matrixes, err = job.GetMatrixes()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, matrixes, //nolint:testifylint // pre-existing issue from nektos/act
|
||||
[]map[string]any{
|
||||
{"datacenter": "site-c", "node-version": "14.x", "site": "staging"},
|
||||
{"datacenter": "site-c", "node-version": "16.x", "site": "staging"},
|
||||
{"datacenter": "site-d", "node-version": "16.x", "site": "staging"},
|
||||
{"php-version": 5.4},
|
||||
{"datacenter": "site-a", "node-version": "10.x", "site": "prod"},
|
||||
{"datacenter": "site-b", "node-version": "12.x", "site": "dev"},
|
||||
},
|
||||
)
|
||||
assert.Equal(t, job.Matrix(), //nolint:testifylint // pre-existing issue from nektos/act
|
||||
map[string][]any{
|
||||
"datacenter": {"site-c", "site-d"},
|
||||
"exclude": {
|
||||
map[string]any{"datacenter": "site-d", "node-version": "14.x", "site": "staging"},
|
||||
},
|
||||
"include": {
|
||||
map[string]any{"php-version": 5.4},
|
||||
map[string]any{"datacenter": "site-a", "node-version": "10.x", "site": "prod"},
|
||||
map[string]any{"datacenter": "site-b", "node-version": "12.x", "site": "dev"},
|
||||
},
|
||||
"node-version": {"14.x", "16.x"},
|
||||
"site": {"staging"},
|
||||
},
|
||||
)
|
||||
assert.Equal(t, job.Strategy.MaxParallel, 2) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, job.Strategy.FailFast, false) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
func TestStep_ShellCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
shell string
|
||||
want string
|
||||
}{
|
||||
{"pwsh -v '. {0}'", "pwsh -v '. {0}'"},
|
||||
{"pwsh", "pwsh -command . '{0}'"},
|
||||
{"powershell", "powershell -command . '{0}'"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.shell, func(t *testing.T) {
|
||||
got := (&Step{Shell: tt.shell}).ShellCommand()
|
||||
assert.Equal(t, got, tt.want) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
`
|
||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
workflowDispatch := workflow.WorkflowDispatchConfig()
|
||||
assert.Nil(t, workflowDispatch)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on: push
|
||||
`
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
workflowDispatch = workflow.WorkflowDispatchConfig()
|
||||
assert.Nil(t, workflowDispatch)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on: workflow_dispatch
|
||||
`
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
workflowDispatch = workflow.WorkflowDispatchConfig()
|
||||
assert.NotNil(t, workflowDispatch)
|
||||
assert.Nil(t, workflowDispatch.Inputs)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on: [push, pull_request]
|
||||
`
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
workflowDispatch = workflow.WorkflowDispatchConfig()
|
||||
assert.Nil(t, workflowDispatch)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on: [push, workflow_dispatch]
|
||||
`
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
workflowDispatch = workflow.WorkflowDispatchConfig()
|
||||
assert.NotNil(t, workflowDispatch)
|
||||
assert.Nil(t, workflowDispatch.Inputs)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
- push
|
||||
- workflow_dispatch
|
||||
`
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
workflowDispatch = workflow.WorkflowDispatchConfig()
|
||||
assert.NotNil(t, workflowDispatch)
|
||||
assert.Nil(t, workflowDispatch.Inputs)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
`
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
workflowDispatch = workflow.WorkflowDispatchConfig()
|
||||
assert.Nil(t, workflowDispatch)
|
||||
|
||||
yaml = `
|
||||
name: local-action-docker-url
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
required: true
|
||||
default: 'warning'
|
||||
type: choice
|
||||
options:
|
||||
- info
|
||||
- warning
|
||||
- debug
|
||||
`
|
||||
workflow, err = ReadWorkflow(strings.NewReader(yaml))
|
||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
workflowDispatch = workflow.WorkflowDispatchConfig()
|
||||
assert.NotNil(t, workflowDispatch)
|
||||
assert.Equal(t, WorkflowDispatchInput{
|
||||
Default: "warning",
|
||||
Description: "Log level",
|
||||
Options: []string{
|
||||
"info",
|
||||
"warning",
|
||||
"debug",
|
||||
},
|
||||
Required: true,
|
||||
Type: "choice",
|
||||
}, workflowDispatch.Inputs["logLevel"])
|
||||
}
|
||||
|
||||
func TestStep_UsesHash(t *testing.T) {
|
||||
type fields struct {
|
||||
Uses string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "regular",
|
||||
fields: fields{
|
||||
Uses: "https://gitea.com/testa/testb@v3",
|
||||
},
|
||||
want: "ae437878e9f285bd7518c58664f9fabbb12d05feddd7169c01702a2a14322aa8",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
fields: fields{
|
||||
Uses: "",
|
||||
},
|
||||
want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &Step{
|
||||
Uses: tt.fields.Uses,
|
||||
}
|
||||
assert.Equalf(t, tt.want, s.UsesHash(), "UsesHash()")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMatrixValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value any
|
||||
wantResult []any
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "array_values_pass_through",
|
||||
key: "version",
|
||||
value: []any{"1.0", "2.0", "3.0"},
|
||||
wantResult: []any{"1.0", "2.0", "3.0"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "string_scalar_wrapped",
|
||||
key: "os",
|
||||
value: "ubuntu-latest",
|
||||
wantResult: []any{"ubuntu-latest"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "template_expression_wrapped",
|
||||
key: "version",
|
||||
value: "${{ fromJson(needs.setup.outputs.versions) }}",
|
||||
wantResult: []any{"${{ fromJson(needs.setup.outputs.versions) }}"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "integer_scalar_wrapped",
|
||||
key: "count",
|
||||
value: 42,
|
||||
wantResult: []any{42},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "float_scalar_wrapped",
|
||||
key: "factor",
|
||||
value: 3.14,
|
||||
wantResult: []any{3.14},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "bool_scalar_wrapped",
|
||||
key: "enabled",
|
||||
value: true,
|
||||
wantResult: []any{true},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nil_scalar_wrapped",
|
||||
key: "optional",
|
||||
value: nil,
|
||||
wantResult: []any{nil},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nested_map_returns_error",
|
||||
key: "config",
|
||||
value: map[string]any{"nested": "value"},
|
||||
wantErr: true,
|
||||
errMsg: "has invalid nested object value",
|
||||
},
|
||||
{
|
||||
name: "empty_array_passes_through",
|
||||
key: "empty",
|
||||
value: []any{},
|
||||
wantResult: []any{},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := normalizeMatrixValue(tt.key, tt.value)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err, "should return error") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
if tt.errMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err, "should not return error") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, tt.wantResult, result, "result should match expected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobMatrix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
wantErr bool
|
||||
wantLen int
|
||||
checkFn func(*testing.T, map[string][]any)
|
||||
}{
|
||||
{
|
||||
name: "matrix_with_arrays",
|
||||
yaml: `
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
version: [1.18, 1.19]
|
||||
steps:
|
||||
- run: echo test
|
||||
`,
|
||||
wantErr: false,
|
||||
wantLen: 2,
|
||||
checkFn: func(t *testing.T, m map[string][]any) {
|
||||
assert.Equal(t, []any{"ubuntu-latest", "windows-latest"}, m["os"])
|
||||
assert.Equal(t, []any{1.18, 1.19}, m["version"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matrix_with_scalar_values",
|
||||
yaml: `
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: ubuntu-latest
|
||||
version: 1.19
|
||||
steps:
|
||||
- run: echo test
|
||||
`,
|
||||
wantErr: false,
|
||||
wantLen: 2,
|
||||
checkFn: func(t *testing.T, m map[string][]any) {
|
||||
assert.Equal(t, []any{"ubuntu-latest"}, m["os"])
|
||||
assert.Equal(t, []any{1.19}, m["version"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matrix_with_template_expression",
|
||||
yaml: `
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
versions: ${{ fromJson(needs.setup.outputs.versions) }}
|
||||
steps:
|
||||
- run: echo test
|
||||
`,
|
||||
wantErr: false,
|
||||
wantLen: 1,
|
||||
checkFn: func(t *testing.T, m map[string][]any) {
|
||||
assert.Equal(t, []any{"${{ fromJson(needs.setup.outputs.versions) }}"}, m["versions"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matrix_mixed_arrays_and_scalars",
|
||||
yaml: `
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
version: 1.19
|
||||
node: [14, 16]
|
||||
steps:
|
||||
- run: echo test
|
||||
`,
|
||||
wantErr: false,
|
||||
wantLen: 3,
|
||||
checkFn: func(t *testing.T, m map[string][]any) {
|
||||
assert.Equal(t, []any{"ubuntu-latest", "windows-latest"}, m["os"])
|
||||
assert.Equal(t, []any{1.19}, m["version"])
|
||||
assert.Equal(t, []any{14, 16}, m["node"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty_matrix",
|
||||
yaml: `
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo test
|
||||
`,
|
||||
wantErr: false,
|
||||
wantLen: 0,
|
||||
checkFn: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
workflow, err := ReadWorkflow(strings.NewReader(tt.yaml))
|
||||
assert.NoError(t, err, "reading workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
job := workflow.GetJob("build")
|
||||
if job == nil {
|
||||
// For empty matrix test
|
||||
if tt.wantLen == 0 {
|
||||
return
|
||||
}
|
||||
t.Fatal("job not found")
|
||||
}
|
||||
|
||||
matrix := job.Matrix()
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Nil(t, matrix, "matrix should be nil on error")
|
||||
} else {
|
||||
if tt.wantLen == 0 {
|
||||
assert.Nil(t, matrix, "matrix should be nil for jobs without strategy")
|
||||
} else {
|
||||
assert.NotNil(t, matrix, "matrix should not be nil")
|
||||
assert.Len(t, matrix, tt.wantLen, "matrix should have expected number of keys")
|
||||
if tt.checkFn != nil {
|
||||
tt.checkFn(t, matrix)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobMatrixValidation(t *testing.T) {
|
||||
// This test verifies that invalid nested map values are caught
|
||||
t.Run("matrix_with_nested_map_fails", func(t *testing.T) {
|
||||
// Manually construct a job with a problematic matrix containing a nested map
|
||||
job := &Job{
|
||||
Strategy: &Strategy{
|
||||
RawMatrix: yaml.Node{
|
||||
Kind: yaml.MappingNode,
|
||||
Content: []*yaml.Node{
|
||||
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "config"},
|
||||
{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
|
||||
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "nested"},
|
||||
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "value"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Attempt to get matrix
|
||||
matrix := job.Matrix()
|
||||
|
||||
// Should return nil due to validation error
|
||||
assert.Nil(t, matrix, "matrix with nested map should return nil")
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,9 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -21,8 +23,8 @@ import (
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"gitea.dev/actions-proto-go/pkg/model"
|
||||
"github.com/kballard/go-shellquote"
|
||||
)
|
||||
|
||||
@@ -127,6 +129,16 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath
|
||||
return action, err
|
||||
}
|
||||
|
||||
// cachedActionTar returns the action's tree from the action cache, which only a remote action
|
||||
// has an entry in.
|
||||
func cachedActionTar(ctx context.Context, step actionStep, name, includePrefix string) (io.ReadCloser, error) {
|
||||
remote, ok := step.(*stepActionRemote)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("action %q is a remote action but runs as %T", name, step)
|
||||
}
|
||||
return step.getRunContext().Config.ActionCache.GetTarArchive(ctx, remote.cacheDir, remote.resolvedSha, includePrefix)
|
||||
}
|
||||
|
||||
func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error {
|
||||
logger := common.Logger(ctx)
|
||||
rc := step.getRunContext()
|
||||
@@ -145,8 +157,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
||||
}
|
||||
|
||||
if rc.Config != nil && rc.Config.ActionCache != nil {
|
||||
raction := step.(*stepActionRemote)
|
||||
ta, err := rc.Config.ActionCache.GetTarArchive(ctx, raction.cacheDir, raction.resolvedSha, "")
|
||||
ta, err := cachedActionTar(ctx, step, stepModel.Uses, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,7 +216,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
||||
if remoteAction == nil {
|
||||
location = containerActionDir
|
||||
}
|
||||
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil)
|
||||
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil, stepStageMain)
|
||||
case x.IsComposite():
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
@@ -272,8 +283,38 @@ func removeGitIgnore(ctx context.Context, directory string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// dockerActionImageTag derives the local docker image tag used when an action
|
||||
// is built from a Dockerfile.
|
||||
//
|
||||
// For Gitea: a local action (`uses: ./` or `uses: ./path`) has an actionName
|
||||
// that is the workspace-relative path of the action. That path is identical
|
||||
// across repositories (e.g. "./" for a self-referencing action), so without
|
||||
// namespacing, every repository's local docker action would build and reuse the
|
||||
// same `act-dockeraction:latest` image on a shared docker daemon. A subsequent
|
||||
// repository would then silently run the image built for an earlier one.
|
||||
// Including the repository keeps the tag stable for caching within a repository
|
||||
// while preventing cross-repository collisions.
|
||||
// See https://gitea.com/gitea/runner/issues/1039.
|
||||
func dockerActionImageTag(repository, actionName string, localAction bool) string {
|
||||
name := actionName
|
||||
if localAction {
|
||||
name = path.Join(repository, actionName)
|
||||
}
|
||||
// The human-readable name is sanitized by collapsing every non-alphanumeric character to "-".
|
||||
sanitized := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(name, "-")
|
||||
if localAction {
|
||||
// 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))
|
||||
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
|
||||
image := fmt.Sprintf("%s-dockeraction:%s", sanitized, "latest")
|
||||
image = "act-" + strings.TrimLeft(image, "-")
|
||||
return strings.ToLower(image)
|
||||
}
|
||||
|
||||
// TODO: break out parts of function to reduce complexicity
|
||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
|
||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
|
||||
logger := common.Logger(ctx)
|
||||
rc := step.getRunContext()
|
||||
action := step.getActionModel()
|
||||
@@ -286,10 +327,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
// Apply forcePull only for prebuild docker images
|
||||
forcePull = rc.Config.ForcePull
|
||||
} else {
|
||||
// "-dockeraction" enshures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
|
||||
image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest")
|
||||
image = "act-" + strings.TrimLeft(image, "-")
|
||||
image = strings.ToLower(image)
|
||||
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction)
|
||||
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
||||
|
||||
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
||||
@@ -322,8 +360,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
}
|
||||
defer buildContext.Close()
|
||||
} else if rc.Config.ActionCache != nil {
|
||||
rstep := step.(*stepActionRemote)
|
||||
buildContext, err = rc.Config.ActionCache.GetTarArchive(ctx, rstep.cacheDir, rstep.resolvedSha, contextDir)
|
||||
buildContext, err = cachedActionTar(ctx, step, actionName, contextDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -335,6 +372,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
ImageTag: image,
|
||||
BuildContext: buildContext,
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
BuildArgs: rc.proxyBuildArgs(),
|
||||
})
|
||||
if buildContext == nil {
|
||||
// Held across the whole build: the daemon drains contextDir lazily.
|
||||
@@ -357,16 +395,9 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
cmd = action.Runs.Args
|
||||
evalDockerArgs(ctx, step, action, &cmd)
|
||||
}
|
||||
entrypoint := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"]))
|
||||
if len(entrypoint) == 0 {
|
||||
if action.Runs.Entrypoint != "" {
|
||||
entrypoint, err = shellquote.Split(action.Runs.Entrypoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
entrypoint = nil
|
||||
}
|
||||
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
|
||||
return common.NewPipelineExecutor(
|
||||
@@ -376,10 +407,34 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
stepContainer.Start(true),
|
||||
).Finally(
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
|
||||
).Finally(stepContainer.Close())(ctx)
|
||||
}
|
||||
|
||||
// dockerEntrypoint returns the entrypoint the action's image runs with for the given
|
||||
// stage. Only the main stage honours the `entrypoint` input.
|
||||
func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvaluator, stage stepStage) ([]string, error) {
|
||||
runs := step.getActionModel().Runs
|
||||
|
||||
var entrypoint string
|
||||
switch stage {
|
||||
case stepStagePre:
|
||||
entrypoint = runs.PreEntrypoint
|
||||
case stepStagePost:
|
||||
entrypoint = runs.PostEntrypoint
|
||||
default:
|
||||
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
|
||||
return fields, nil
|
||||
}
|
||||
entrypoint = runs.Entrypoint
|
||||
}
|
||||
|
||||
if entrypoint == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return shellquote.Split(entrypoint)
|
||||
}
|
||||
|
||||
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
@@ -426,23 +481,18 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
|
||||
binds, mounts := rc.GetBindsAndMounts()
|
||||
networkMode := "container:" + rc.jobContainerName()
|
||||
if rc.IsHostEnv(ctx) {
|
||||
networkMode = "default"
|
||||
}
|
||||
stepContainer := container.NewContainer(&container.NewContainerInput{
|
||||
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
||||
Cmd: cmd,
|
||||
Entrypoint: entrypoint,
|
||||
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||
Image: image,
|
||||
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
||||
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
|
||||
Env: envList,
|
||||
Mounts: mounts,
|
||||
@@ -455,7 +505,8 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
Options: rc.Config.ContainerOptions,
|
||||
AutoRemove: rc.Config.AutoRemove,
|
||||
ValidVolumes: rc.Config.ValidVolumes,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
return stepContainer
|
||||
}
|
||||
@@ -531,44 +582,57 @@ func hasPreStep(step actionStep) common.Conditional {
|
||||
return action.Runs.Using.IsComposite() ||
|
||||
(action.Runs.Using.IsNode() &&
|
||||
action.Runs.Pre != "") ||
|
||||
(action.Runs.Using.IsDocker() &&
|
||||
action.Runs.PreEntrypoint != "") ||
|
||||
(action.Runs.Using == model.ActionRunsUsingGo &&
|
||||
action.Runs.Pre != "")
|
||||
}
|
||||
}
|
||||
|
||||
// actionStagePaths resolves where a step's action lives and where the job container sees
|
||||
// it, for the pre and post stage.
|
||||
func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, containerActionDir string) {
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
|
||||
if sar, ok := step.(*stepActionRemote); ok {
|
||||
actionDir = sar.actionDir()
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
}
|
||||
|
||||
actionName, containerActionDir = getContainerActionPaths(stepModel, path.Join(actionDir, actionPath), rc)
|
||||
return actionDir, actionPath, actionName, containerActionDir
|
||||
}
|
||||
|
||||
// execDockerActionStage runs a docker action's image for its pre or post stage.
|
||||
func execDockerActionStage(ctx context.Context, step actionStep, stage stepStage) error {
|
||||
actionDir, actionPath, actionName, containerActionDir := actionStagePaths(step)
|
||||
|
||||
_, remote := step.(*stepActionRemote)
|
||||
location := containerActionDir
|
||||
if remote {
|
||||
location = path.Join(actionDir, actionPath)
|
||||
}
|
||||
return execAsDocker(ctx, step, actionName, actionDir, location, !remote, stage)
|
||||
}
|
||||
|
||||
func runPreStep(step actionStep) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("run pre step for '%s'", step.getStepModel())
|
||||
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
action := step.getActionModel()
|
||||
|
||||
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
||||
|
||||
x := action.Runs.Using
|
||||
switch {
|
||||
case x.IsNode():
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
// todo: refactor into step
|
||||
var actionDir string
|
||||
var actionPath string
|
||||
if _, ok := step.(*stepActionRemote); ok {
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
actionPath = ""
|
||||
}
|
||||
|
||||
var actionLocation string
|
||||
if actionPath != "" {
|
||||
actionLocation = path.Join(actionDir, actionPath)
|
||||
} else {
|
||||
actionLocation = actionDir
|
||||
}
|
||||
|
||||
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
@@ -581,6 +645,12 @@ func runPreStep(step actionStep) common.Executor {
|
||||
|
||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||
|
||||
case x.IsDocker():
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
|
||||
return execDockerActionStage(ctx, step, stepStagePre)
|
||||
|
||||
case x.IsComposite():
|
||||
if step.getCompositeSteps() == nil {
|
||||
step.getCompositeRunContext(ctx)
|
||||
@@ -594,25 +664,6 @@ func runPreStep(step actionStep) common.Executor {
|
||||
case x == model.ActionRunsUsingGo:
|
||||
// defaults in pre steps were missing, however provided inputs are available
|
||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
||||
// todo: refactor into step
|
||||
var actionDir string
|
||||
var actionPath string
|
||||
if _, ok := step.(*stepActionRemote); ok {
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
actionPath = ""
|
||||
}
|
||||
|
||||
var actionLocation string
|
||||
if actionPath != "" {
|
||||
actionLocation = path.Join(actionDir, actionPath)
|
||||
} else {
|
||||
actionLocation = actionDir
|
||||
}
|
||||
|
||||
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
@@ -665,6 +716,8 @@ func hasPostStep(step actionStep) common.Conditional {
|
||||
return action.Runs.Using.IsComposite() ||
|
||||
(action.Runs.Using.IsNode() &&
|
||||
action.Runs.Post != "") ||
|
||||
(action.Runs.Using.IsDocker() &&
|
||||
action.Runs.PostEntrypoint != "") ||
|
||||
(action.Runs.Using == model.ActionRunsUsingGo &&
|
||||
action.Runs.Post != "")
|
||||
}
|
||||
@@ -676,28 +729,9 @@ func runPostStep(step actionStep) common.Executor {
|
||||
logger.Debugf("run post step for '%s'", step.getStepModel())
|
||||
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
action := step.getActionModel()
|
||||
|
||||
// todo: refactor into step
|
||||
var actionDir string
|
||||
var actionPath string
|
||||
if _, ok := step.(*stepActionRemote); ok {
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
actionPath = ""
|
||||
}
|
||||
|
||||
var actionLocation string
|
||||
if actionPath != "" {
|
||||
actionLocation = path.Join(actionDir, actionPath)
|
||||
} else {
|
||||
actionLocation = actionDir
|
||||
}
|
||||
|
||||
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
||||
|
||||
x := action.Runs.Using
|
||||
switch {
|
||||
@@ -712,6 +746,11 @@ func runPostStep(step actionStep) common.Executor {
|
||||
|
||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||
|
||||
case x.IsDocker():
|
||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||
|
||||
return execDockerActionStage(ctx, step, stepStagePost)
|
||||
|
||||
case x.IsComposite():
|
||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path"
|
||||
|
||||
git "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
)
|
||||
|
||||
type GoGitActionCacheOfflineMode struct {
|
||||
Parent GoGitActionCache
|
||||
}
|
||||
|
||||
func (c GoGitActionCacheOfflineMode) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
|
||||
sha, fetchErr := c.Parent.Fetch(ctx, cacheDir, url, ref, token)
|
||||
gitPath := path.Join(c.Parent.Path, safeFilename(cacheDir)+".git")
|
||||
gogitrepo, err := git.PlainOpen(gitPath)
|
||||
if err != nil {
|
||||
return "", fetchErr
|
||||
}
|
||||
refName := plumbing.ReferenceName("refs/action-cache-offline/" + ref)
|
||||
r, err := gogitrepo.Reference(refName, true)
|
||||
if fetchErr == nil {
|
||||
if err != nil || sha != r.Hash().String() {
|
||||
if err == nil {
|
||||
refName = r.Name()
|
||||
}
|
||||
ref := plumbing.NewHashReference(refName, plumbing.NewHash(sha))
|
||||
_ = gogitrepo.Storer.SetReference(ref)
|
||||
}
|
||||
} else if err == nil {
|
||||
return r.Hash().String(), nil
|
||||
}
|
||||
return sha, fetchErr
|
||||
}
|
||||
|
||||
func (c GoGitActionCacheOfflineMode) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
|
||||
return c.Parent.GetTarArchive(ctx, cacheDir, sha, includePrefix)
|
||||
}
|
||||
@@ -8,64 +8,139 @@ import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"gitea.dev/actions-proto-go/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionCache(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
if dir != "" {
|
||||
args = append([]string{"-C", dir}, args...)
|
||||
}
|
||||
cmd := exec.Command("git", args...)
|
||||
// Fixed identity and host-config isolation so commits succeed offline regardless of the
|
||||
// host's git config (mirrors gitCmd in act/common/git).
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
|
||||
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
}
|
||||
|
||||
// TestShortShaActionRejected verifies a `uses` ref that is a shortened commit SHA is rejected
|
||||
// with a clear error. The action is resolved from a local repo (via DefaultActionInstance) so
|
||||
// this runs offline.
|
||||
func TestShortShaActionRejected(t *testing.T) {
|
||||
// a local "remote" action repo at <root>/actions/hello-world-docker-action
|
||||
actionRoot := t.TempDir()
|
||||
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
|
||||
require.NoError(t, os.MkdirAll(repo, 0o755))
|
||||
runGit(t, "", "init", "--initial-branch=main", repo)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
|
||||
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "initial")
|
||||
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
|
||||
require.NoError(t, err)
|
||||
shortSha := strings.TrimSpace(string(out))[:7]
|
||||
|
||||
// a workflow that uses the action at the short SHA
|
||||
wfDir := filepath.Join(t.TempDir(), "wf")
|
||||
require.NoError(t, os.MkdirAll(wfDir, 0o755))
|
||||
wf := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", shortSha)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(wfDir, "push.yml"), []byte(wf), 0o644))
|
||||
|
||||
runner, err := New(&Config{
|
||||
Workdir: wfDir,
|
||||
EventName: "push",
|
||||
Platforms: map[string]string{"ubuntu-latest": baseImage},
|
||||
GitHubInstance: "github.com",
|
||||
DefaultActionInstance: actionRoot,
|
||||
ContainerMaxLifetime: time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
planner, err := model.NewWorkflowPlanner(wfDir, true)
|
||||
require.NoError(t, err)
|
||||
plan, err := planner.PlanEvent("push")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = runner.NewPlanExecutor(plan)(common.WithDryrun(context.Background(), true))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "shortened version of a commit SHA")
|
||||
}
|
||||
|
||||
func TestActionCache(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Build a local bare repo with a `js` action dir so this runs offline (formerly cloned
|
||||
// github.com/nektos/act-test-actions over the network). allowAnySHA1InWant lets the
|
||||
// "Fetch Sha" case fetch a commit hash directly.
|
||||
remoteDir := t.TempDir()
|
||||
runGit(t, "", "init", "--bare", "--initial-branch=main", remoteDir)
|
||||
runGit(t, remoteDir, "config", "uploadpack.allowAnySHA1InWant", "true")
|
||||
|
||||
workDir := t.TempDir()
|
||||
runGit(t, "", "clone", remoteDir, workDir)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(workDir, "js"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "action.yml"),
|
||||
[]byte("name: js\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "index.js"),
|
||||
[]byte("console.log('hello');\n"), 0o644))
|
||||
runGit(t, workDir, "add", ".")
|
||||
runGit(t, workDir, "commit", "-m", "initial")
|
||||
runGit(t, workDir, "push", "-u", "origin", "main")
|
||||
|
||||
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
|
||||
require.NoError(t, err)
|
||||
fullSha := strings.TrimSpace(string(out))
|
||||
|
||||
cache := &GoGitActionCache{
|
||||
Path: t.TempDir(),
|
||||
}
|
||||
ctx := context.Background()
|
||||
cacheDir := "nektos/act-test-actions"
|
||||
repo := "https://github.com/nektos/act-test-actions"
|
||||
cacheDir := "local/act-test-actions"
|
||||
refs := []struct {
|
||||
Name string
|
||||
CacheDir string
|
||||
Repo string
|
||||
Ref string
|
||||
Name string
|
||||
Ref string
|
||||
}{
|
||||
{
|
||||
Name: "Fetch Branch Name",
|
||||
CacheDir: cacheDir,
|
||||
Repo: repo,
|
||||
Ref: "main",
|
||||
},
|
||||
{
|
||||
Name: "Fetch Branch Name Absolutely",
|
||||
CacheDir: cacheDir,
|
||||
Repo: repo,
|
||||
Ref: "refs/heads/main",
|
||||
},
|
||||
{
|
||||
Name: "Fetch HEAD",
|
||||
CacheDir: cacheDir,
|
||||
Repo: repo,
|
||||
Ref: "HEAD",
|
||||
},
|
||||
{
|
||||
Name: "Fetch Sha",
|
||||
CacheDir: cacheDir,
|
||||
Repo: repo,
|
||||
Ref: "de984ca37e4df4cb9fd9256435a3b82c4a2662b1",
|
||||
},
|
||||
{Name: "Fetch Branch Name", Ref: "main"},
|
||||
{Name: "Fetch Branch Name Absolutely", Ref: "refs/heads/main"},
|
||||
{Name: "Fetch HEAD", Ref: "HEAD"},
|
||||
{Name: "Fetch Sha", Ref: fullSha},
|
||||
}
|
||||
for _, c := range refs {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
sha, err := cache.Fetch(ctx, c.CacheDir, c.Repo, c.Ref, "")
|
||||
sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "")
|
||||
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
return
|
||||
}
|
||||
atar, err := cache.GetTarArchive(ctx, c.CacheDir, sha, "js")
|
||||
if !a.NoError(err) || !a.NotEmpty(atar) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
atar, err := cache.GetTarArchive(ctx, cacheDir, sha, "js")
|
||||
// NotNil, not NotEmpty: atar is a live io.PipeReader whose producer goroutine is
|
||||
// writing concurrently; NotEmpty deep-reflects over its internals and races.
|
||||
if !a.NoError(err) || !a.NotNil(atar) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
return
|
||||
}
|
||||
// GetTarArchive streams from a background goroutine walking the shared repo.
|
||||
// Drain and close so it finishes before the next subtest fetches into the same
|
||||
// repo; otherwise the lingering walk races with that fetch.
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, atar)
|
||||
_ = atar.Close()
|
||||
}()
|
||||
mytar := tar.NewReader(atar)
|
||||
th, err := mytar.Next()
|
||||
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"gitea.dev/actions-proto-go/pkg/model"
|
||||
)
|
||||
|
||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
||||
@@ -85,6 +87,19 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
||||
return compositerc
|
||||
}
|
||||
|
||||
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
||||
// is already present in dst. This prevents the parent RunContext's Masks slice
|
||||
// from growing exponentially when composite actions are nested or repeated,
|
||||
// since each composite RunContext is seeded with its parent's masks.
|
||||
func appendUniqueMasks(dst, src []string) []string {
|
||||
for _, m := range src {
|
||||
if !slices.Contains(dst, m) {
|
||||
dst = append(dst, m)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func execAsComposite(step actionStep) common.Executor {
|
||||
rc := step.getRunContext()
|
||||
action := step.getActionModel()
|
||||
@@ -110,7 +125,11 @@ func execAsComposite(step actionStep) common.Executor {
|
||||
}, eval.Interpolate(ctx, output.Value))
|
||||
}
|
||||
|
||||
rc.Masks = append(rc.Masks, compositeRC.Masks...)
|
||||
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
||||
// and may have additional masks appended while the composite action runs.
|
||||
// Only append masks that are not already present, otherwise nested or
|
||||
// repeated composite actions grow rc.Masks exponentially.
|
||||
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
||||
rc.ExtraPath = compositeRC.ExtraPath
|
||||
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
|
||||
mergeIntoMap := mergeIntoMapCaseSensitive
|
||||
|
||||
70
act/runner/action_composite_test.go
Normal file
70
act/runner/action_composite_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAppendUniqueMasks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dst []string
|
||||
src []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "appends new masks",
|
||||
dst: []string{"a"},
|
||||
src: []string{"b", "c"},
|
||||
want: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "skips masks already present",
|
||||
dst: []string{"a", "b"},
|
||||
src: []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",
|
||||
dst: []string{"a"},
|
||||
src: nil,
|
||||
want: []string{"a"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, appendUniqueMasks(tt.dst, tt.src))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -16,10 +16,11 @@ import (
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"gitea.dev/actions-proto-go/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type closerMock struct {
|
||||
@@ -150,6 +151,44 @@ runs:
|
||||
}
|
||||
}
|
||||
|
||||
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
|
||||
func TestExecAsDockerAutoRemove(t *testing.T) {
|
||||
orig := ContainerNewContainer
|
||||
defer func() { ContainerNewContainer = orig }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
autoRemove bool
|
||||
removes int
|
||||
}{
|
||||
{false, 2}, // stale + post-run
|
||||
{true, 1}, // post-run skipped
|
||||
} {
|
||||
cm := &containerMock{}
|
||||
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return cm }
|
||||
|
||||
step := &stepActionRemote{
|
||||
Step: &model.Step{ID: "1", Uses: "org/action@v1"},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{AutoRemove: tc.autoRemove},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: cm,
|
||||
},
|
||||
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "docker://node:14"}},
|
||||
}
|
||||
|
||||
removes := 0
|
||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||
cm.On("Remove").Return(func(context.Context) error { removes++; return nil })
|
||||
cm.On("Create", []string(nil), []string(nil)).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 })
|
||||
|
||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, stepStageMain))
|
||||
cm.AssertExpectations(t)
|
||||
assert.Equal(t, tc.removes, removes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunner(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
@@ -258,6 +297,54 @@ func TestActionRunner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
|
||||
var captured *container.NewContainerInput
|
||||
origContainerNewContainer := ContainerNewContainer
|
||||
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
captured = input
|
||||
return cm
|
||||
}
|
||||
defer func() {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
rc := &RunContext{
|
||||
Name: "job",
|
||||
Config: &Config{
|
||||
Secrets: map[string]string{
|
||||
"DOCKER_USERNAME": "docker-user",
|
||||
"DOCKER_PASSWORD": "docker-password",
|
||||
},
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "job",
|
||||
Workflow: &model.Workflow{
|
||||
Name: "test",
|
||||
Jobs: map[string]*model.Job{
|
||||
"job": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
JobContainer: cm,
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
}
|
||||
env := map[string]string{}
|
||||
step := &stepMock{}
|
||||
step.On("getRunContext").Return(rc)
|
||||
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
||||
step.On("getEnv").Return(&env)
|
||||
|
||||
_ = 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.Password)
|
||||
step.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -378,7 +465,7 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false) }()
|
||||
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false, stepStageMain) }()
|
||||
|
||||
select {
|
||||
case <-innerEntered:
|
||||
@@ -407,3 +494,133 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
||||
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
"act-abc123-dockeraction:latest",
|
||||
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,
|
||||
"act-owner-repo-baca2daaa2fe-dockeraction:latest",
|
||||
dockerActionImageTag("owner/repo", "./", true),
|
||||
)
|
||||
assert.Equal(t,
|
||||
"act-owner-repo-sub-e847b61255a8-dockeraction:latest",
|
||||
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 {
|
||||
repoName 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 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"}},
|
||||
}
|
||||
for _, c := range collisions {
|
||||
assert.NotEqual(t,
|
||||
dockerActionImageTag(c[0].repoName, c[0].actionName, true),
|
||||
dockerActionImageTag(c[1].repoName, c[1].actionName, true),
|
||||
"local docker action tags must differ for %q/%q vs %q/%q",
|
||||
c[0].repoName, c[0].actionName, c[1].repoName, c[1].actionName,
|
||||
)
|
||||
}
|
||||
|
||||
// Distinct local actions within the same repository keep distinct tags.
|
||||
assert.NotEqual(t,
|
||||
dockerActionImageTag("owner/repo", "./", true),
|
||||
dockerActionImageTag("owner/repo", "./sub", true),
|
||||
)
|
||||
}
|
||||
|
||||
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
|
||||
// and runs.env, and the `entrypoint` input applies to the main stage alone.
|
||||
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
||||
orig := ContainerNewContainer
|
||||
defer func() { ContainerNewContainer = orig }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stage stepStage
|
||||
wantEntrypoint []string
|
||||
}{
|
||||
{
|
||||
name: "main stage prefers the entrypoint input",
|
||||
stage: stepStageMain,
|
||||
wantEntrypoint: []string{"input.sh"},
|
||||
},
|
||||
{
|
||||
name: "pre stage uses runs.pre-entrypoint",
|
||||
stage: stepStagePre,
|
||||
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
||||
},
|
||||
{
|
||||
name: "post stage uses runs.post-entrypoint",
|
||||
stage: stepStagePost,
|
||||
wantEntrypoint: []string{"post.sh"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cm := &containerMock{}
|
||||
var input *container.NewContainerInput
|
||||
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
input = in
|
||||
return cm
|
||||
}
|
||||
|
||||
step := &stepActionRemote{
|
||||
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: cm,
|
||||
},
|
||||
action: &model.Action{Runs: model.ActionRuns{
|
||||
Using: "docker",
|
||||
Image: "docker://node:14",
|
||||
PreEntrypoint: "pre.sh --verbose",
|
||||
Entrypoint: "main.sh",
|
||||
PostEntrypoint: "post.sh",
|
||||
Args: []string{"hello"},
|
||||
Env: map[string]string{"MY_VAR": "world"},
|
||||
}},
|
||||
env: map[string]string{},
|
||||
}
|
||||
|
||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||
cm.On("Remove").Return(func(context.Context) error { return nil })
|
||||
cm.On("Create", []string(nil), []string(nil)).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 })
|
||||
|
||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
||||
require.NotNil(t, input)
|
||||
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
||||
assert.Equal(t, []string{"hello"}, input.Cmd)
|
||||
assert.Contains(t, input.Env, "MY_VAR=world")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerActionHasPreAndPostStep(t *testing.T) {
|
||||
newStep := func(runs model.ActionRuns) actionStep {
|
||||
return &stepActionRemote{action: &model.Action{Runs: runs}}
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||
assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||
|
||||
withStages := model.ActionRuns{Using: "docker", Image: "Dockerfile", PreEntrypoint: "pre.sh", PostEntrypoint: "post.sh"}
|
||||
assert.True(t, hasPreStep(newStep(withStages))(ctx))
|
||||
assert.True(t, hasPostStep(newStep(withStages))(ctx))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user