mirror of
https://gitea.com/gitea/runner.git
synced 2026-08-26 13:57:46 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ea5134ac6 |
@@ -20,10 +20,11 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# Custom publishers (the R2 upload below) run as the very last
|
# Custom publishers (the R2 mirror below) run as the very last
|
||||||
# step of goreleaser's publish pipeline, after the Gitea release
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
# has already been created. Fail here instead, before anything
|
# has already been created and every artifact already uploaded
|
||||||
# is built or published, if the R2 secrets are missing.
|
# to S3. Fail here instead, before anything is built or
|
||||||
|
# published, if the R2 secrets are missing.
|
||||||
- name: check R2 configuration
|
- name: check R2 configuration
|
||||||
run: sh scripts/upload-r2.sh --check-config
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
env:
|
env:
|
||||||
@@ -42,6 +43,11 @@ jobs:
|
|||||||
args: release --nightly
|
args: release --nightly
|
||||||
env:
|
env:
|
||||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||||
|
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||||
|
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
@@ -77,7 +83,7 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
# Custom publishers (the R2 upload below) run as the very last
|
# Custom publishers (the R2 mirror below) run as the very last
|
||||||
# step of goreleaser's publish pipeline, after the Gitea release
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
# has already been created. Fail here instead, before anything
|
# has already been created and every artifact already uploaded
|
||||||
# is built or published, if the R2 secrets are missing.
|
# to S3. Fail here instead, before anything is built or
|
||||||
|
# published, if the R2 secrets are missing.
|
||||||
- name: check R2 configuration
|
- name: check R2 configuration
|
||||||
run: sh scripts/upload-r2.sh --check-config
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
env:
|
env:
|
||||||
@@ -41,6 +42,11 @@ jobs:
|
|||||||
args: release
|
args: release
|
||||||
env:
|
env:
|
||||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||||
|
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||||
|
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
@@ -80,7 +86,7 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
|
|||||||
@@ -5,18 +5,17 @@ on:
|
|||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
|
||||||
# The runner image ships a stale docker.io login; point docker at an empty config so
|
|
||||||
# image pulls go straight to anonymous instead of attempting (and failing) that auth
|
|
||||||
# first. The path must be a literal: the `runner` context is unavailable in workflow-level
|
|
||||||
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
|
|
||||||
# to ~/.docker with the stale credentials.
|
|
||||||
DOCKER_CONFIG: /tmp/docker-noauth
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: check and test
|
name: check and test
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
@@ -29,16 +28,9 @@ jobs:
|
|||||||
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
|
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
|
||||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||||
- name: pre-pull test images
|
- name: pre-pull test images
|
||||||
env:
|
|
||||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 # renovate: datasource=docker
|
|
||||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
|
||||||
run: |
|
run: |
|
||||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
for img in node:24-bookworm-slim nginx:alpine; do
|
||||||
for attempt in 1 2 3; do
|
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
|
||||||
docker pull "$image" && break
|
|
||||||
[ "$attempt" = 3 ] || sleep 5
|
|
||||||
done
|
|
||||||
docker tag "$image" "${image%@*}"
|
|
||||||
done
|
done
|
||||||
- name: lint
|
- name: lint
|
||||||
run: make lint
|
run: make lint
|
||||||
@@ -57,22 +49,3 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
make coverage-report
|
make coverage-report
|
||||||
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
e2e:
|
|
||||||
name: gitea compatibility (${{ matrix.gitea_image }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
gitea_image:
|
|
||||||
- gitea/gitea:latest
|
|
||||||
- gitea/gitea:main-nightly
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
|
||||||
with:
|
|
||||||
go-version-file: 'go.mod'
|
|
||||||
check-latest: true
|
|
||||||
- name: prepare anonymous docker config
|
|
||||||
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
|
||||||
- name: e2e compatibility
|
|
||||||
run: make test-e2e E2E_GITEA_IMAGE=${{ matrix.gitea_image }}
|
|
||||||
|
|||||||
+6
-8
@@ -46,7 +46,8 @@ linters:
|
|||||||
gocritic:
|
gocritic:
|
||||||
enabled-checks:
|
enabled-checks:
|
||||||
- equalFold
|
- equalFold
|
||||||
disabled-checks: []
|
disabled-checks:
|
||||||
|
- ifElseChain
|
||||||
revive:
|
revive:
|
||||||
severity: error
|
severity: error
|
||||||
rules:
|
rules:
|
||||||
@@ -70,14 +71,10 @@ linters:
|
|||||||
- name: unexported-return
|
- name: unexported-return
|
||||||
- name: var-declaration
|
- name: var-declaration
|
||||||
- name: var-naming
|
- name: var-naming
|
||||||
arguments:
|
|
||||||
- [] # AllowList - do not remove as args for the rule are positional and won't work without lists first
|
|
||||||
- [] # DenyList
|
|
||||||
- - skip-initialism-name-checks: true
|
|
||||||
staticcheck:
|
staticcheck:
|
||||||
checks:
|
checks:
|
||||||
- all
|
- all
|
||||||
testifylint: {}
|
- -ST1005
|
||||||
usetesting:
|
usetesting:
|
||||||
os-temp-dir: true
|
os-temp-dir: true
|
||||||
perfsprint:
|
perfsprint:
|
||||||
@@ -95,6 +92,8 @@ linters:
|
|||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
- comments
|
- comments
|
||||||
|
- common-false-positives
|
||||||
|
- legacy
|
||||||
- std-error-handling
|
- std-error-handling
|
||||||
rules:
|
rules:
|
||||||
- linters:
|
- linters:
|
||||||
@@ -119,8 +118,7 @@ formatters:
|
|||||||
- blank
|
- blank
|
||||||
- default
|
- default
|
||||||
gofumpt:
|
gofumpt:
|
||||||
extra:
|
extra-rules: true
|
||||||
group-params: true
|
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
run:
|
run:
|
||||||
|
|||||||
+20
-8
@@ -65,7 +65,7 @@ builds:
|
|||||||
flags:
|
flags:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X gitea.com/gitea/runner/internal/pkg/ver.version={{ .Summary }}
|
- -s -w -X main.version={{ .Summary }}
|
||||||
binary: >-
|
binary: >-
|
||||||
{{ .ProjectName }}-
|
{{ .ProjectName }}-
|
||||||
{{- .Version }}-
|
{{- .Version }}-
|
||||||
@@ -83,12 +83,24 @@ builds:
|
|||||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
|
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
|
||||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
|
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
|
||||||
|
|
||||||
# Uploads every release artifact to Cloudflare R2. The `blobs:` pipe
|
blobs:
|
||||||
# isn't usable here since it authenticates from the global AWS_* env
|
-
|
||||||
# with no per-entry credentials; `publishers:` supports per-entry
|
provider: s3
|
||||||
# `env:` instead, so it's used to invoke scripts/upload-r2.sh once per
|
bucket: "{{ .Env.S3_BUCKET }}"
|
||||||
# artifact. Custom publishers inherit almost nothing from the
|
region: "{{ .Env.S3_REGION }}"
|
||||||
# environment, hence the explicit R2_* forwarding below.
|
directory: "gitea-runner/{{.Version}}"
|
||||||
|
extra_files:
|
||||||
|
- glob: ./**.xz
|
||||||
|
- glob: ./**.sha256
|
||||||
|
|
||||||
|
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
||||||
|
# parallel S3+R2 period (S3 will be removed once migration completes).
|
||||||
|
# A second `blobs:` entry is impossible here since the blob pipe
|
||||||
|
# authenticates from the global AWS_* env with no per-entry
|
||||||
|
# credentials; `publishers:` supports per-entry `env:` instead, so
|
||||||
|
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
||||||
|
# publishers inherit almost nothing from the environment, hence the
|
||||||
|
# explicit R2_* forwarding below.
|
||||||
#
|
#
|
||||||
# This publisher fires 109 times for 73 distinct keys because
|
# This publisher fires 109 times for 73 distinct keys because
|
||||||
# goreleaser's release pipe already registers `release.extra_files`
|
# goreleaser's release pipe already registers `release.extra_files`
|
||||||
@@ -113,7 +125,7 @@ publishers:
|
|||||||
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- formats: [binary]
|
- format: binary
|
||||||
name_template: "{{ .Binary }}"
|
name_template: "{{ .Binary }}"
|
||||||
allow_different_binary_count: true
|
allow_different_binary_count: true
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,3 @@ depending on the prefix:
|
|||||||
encoded forms too.
|
encoded forms too.
|
||||||
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
|
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
|
||||||
decodes exactly those two when folding a location into an annotation.
|
decodes exactly those two when folding a location into an annotation.
|
||||||
|
|
||||||
## End-to-end compatibility tests
|
|
||||||
|
|
||||||
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
|
||||||
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
|
|
||||||
parallel.
|
|
||||||
|
|
||||||
The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated
|
|
||||||
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
|
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
### BUILDER STAGE
|
### BUILDER STAGE
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM golang:1.27-alpine3.23 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`
|
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
||||||
RUN apk add --no-cache make git
|
RUN apk add --no-cache make git
|
||||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.7.2-dind AS dind
|
FROM docker:29.6.2-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.7.2-dind-rootless AS dind-rootless
|
FROM docker:29.6.2-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ GO ?= go
|
|||||||
SHASUM ?= shasum -a 256
|
SHASUM ?= shasum -a 256
|
||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
||||||
XGO_VERSION := go-1.27.x
|
XGO_VERSION := go-1.26.x
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.16 # renovate: datasource=go
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.16 # renovate: datasource=go
|
||||||
|
|
||||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||||
@@ -18,8 +18,8 @@ DOCKER_TAG ?= nightly
|
|||||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 # renovate: datasource=go
|
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.7.0 # renovate: datasource=go
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.6.0 # renovate: datasource=go
|
||||||
|
|
||||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
@@ -72,7 +72,8 @@ else
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
TAGS ?=
|
TAGS ?=
|
||||||
LDFLAGS ?= -X "gitea.com/gitea/runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
|
LDFLAGS ?= -X "main.version=v$(RELASE_VERSION)"
|
||||||
|
VERSION_CHECK_BIN := $(DIST)/version-check$(suffix $(EXECUTABLE))
|
||||||
|
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
all: build
|
all: build
|
||||||
@@ -113,7 +114,19 @@ deps-tools: ## install tool dependencies
|
|||||||
wait
|
wait
|
||||||
|
|
||||||
.PHONY: checks
|
.PHONY: checks
|
||||||
checks: tidy-check fmt-check security-check ## run the non-lint source checks
|
checks: tidy-check fmt-check security-check version-check ## run the non-lint source checks
|
||||||
|
|
||||||
|
.PHONY: version-check
|
||||||
|
version-check: ## verify the version is injected into the binary
|
||||||
|
@mkdir -p $(DIST)
|
||||||
|
@$(GO) build -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) -X "main.version=v0.0.0-injected"' -o $(VERSION_CHECK_BIN) .
|
||||||
|
@case "$$($(VERSION_CHECK_BIN) --version)" in \
|
||||||
|
*v0.0.0-injected*) ;; \
|
||||||
|
*) echo "version injection is broken, the Makefile -X target no longer matches a variable" >&2; exit 1;; \
|
||||||
|
esac
|
||||||
|
@rm -f $(VERSION_CHECK_BIN)
|
||||||
|
@# goreleaser builds releases from its own ldflags, so a stale -X target there ships an unversioned binary
|
||||||
|
@grep -q -- '-X main.version=' .goreleaser.yaml || { echo ".goreleaser.yaml no longer injects main.version" >&2; exit 1; }
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go lint-go-windows ## lint everything
|
lint: lint-go lint-go-windows ## lint everything
|
||||||
@@ -137,7 +150,7 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
|||||||
|
|
||||||
.PHONY: security-check
|
.PHONY: security-check
|
||||||
security-check:
|
security-check:
|
||||||
$(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
tidy: ## run go mod tidy
|
tidy: ## run go mod tidy
|
||||||
@@ -171,15 +184,6 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
|||||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||||
@./scripts/test-dind.sh $(TARGET)
|
@./scripts/test-dind.sh $(TARGET)
|
||||||
|
|
||||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:934240a162082fd8b8a2f90cd5114446443f1eba1c5378f6687167ca405e6584 # renovate: datasource=docker
|
|
||||||
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
|
||||||
E2E_CONCURRENCY ?= 8
|
|
||||||
|
|
||||||
.PHONY: test-e2e
|
|
||||||
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
|
||||||
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh
|
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: $(GOFILES) ## install the runner binary via `go install`
|
install: $(GOFILES) ## install the runner binary via `go install`
|
||||||
$(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)'
|
$(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)'
|
||||||
|
|||||||
@@ -158,32 +158,6 @@ An edit keeps the comments and the key order of the file. Indentation becomes tw
|
|||||||
|
|
||||||
`config get`, `set`, `add` and `remove` use `config.yaml` (or `config.yml`) from the working directory, then from the directory of the binary, and print their choice to stderr. `config init` writes `config.yaml` in the working directory, and refuses to overwrite an existing config without `--force`. Pass `-c` for another path.
|
`config get`, `set`, `add` and `remove` use `config.yaml` (or `config.yml`) from the working directory, then from the directory of the binary, and print their choice to stderr. `config init` writes `config.yaml` in the working directory, and refuses to overwrite an existing config without `--force`. Pass `-c` for another path.
|
||||||
|
|
||||||
#### Tool cache
|
|
||||||
|
|
||||||
Setup actions like `setup-go` install tools into `RUNNER_TOOL_CACHE`, which is `/opt/hostedtoolcache` inside a job. `runner.tool_cache_mode` selects what backs it:
|
|
||||||
|
|
||||||
| Mode | Tool cache | Trade-off |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `none` (default) | Per job, provided by the job image | A version the image lacks is downloaded in every job |
|
|
||||||
| `shared` | One volume reused by every job | Two jobs writing the same tool version at once corrupt it, so use it only with `runner.capacity: 1` |
|
|
||||||
|
|
||||||
With `none`, tools must come from the job image. Install them into `/opt/hostedtoolcache/<tool>/<version>/<arch>`, with an empty `<arch>.complete` file next to the directory:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
RUN GO=$(curl -fsSL 'https://go.dev/dl/?mode=json' | grep -oP '"version": "\Kgo1\.26\.[0-9]*' | head -1); \
|
|
||||||
DIR="/opt/hostedtoolcache/go/${GO#go}/x64" && \
|
|
||||||
mkdir -p "$(dirname "$DIR")" && \
|
|
||||||
curl -fsSL "https://dl.google.com/go/${GO}.linux-amd64.tar.gz" | tar -xz -C /tmp && \
|
|
||||||
mv /tmp/go "$DIR" && \
|
|
||||||
touch "${DIR}.complete"
|
|
||||||
```
|
|
||||||
|
|
||||||
A workflow requesting a minor version, `go-version: "1.26"`, resolves to the newest matching version in the cache, so a patch update in the image still hits it.
|
|
||||||
|
|
||||||
Of the [runner images](https://gitea.com/gitea/runner-images), the `-full` flavour is the one that ships tools in this layout.
|
|
||||||
|
|
||||||
`gitea-runner exec` reads no config file and takes `--tool-cache-mode` instead, defaulting to `none`.
|
|
||||||
|
|
||||||
#### Environment variables
|
#### Environment variables
|
||||||
|
|
||||||
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||||
@@ -228,7 +202,7 @@ a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubunt
|
|||||||
|
|
||||||
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
||||||
|
|
||||||
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
If a job's `runs-on` matches none of the runner's labels, 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.
|
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.
|
||||||
|
|
||||||
@@ -299,12 +273,6 @@ A password in a proxy URL is hidden in job logs. Any step can still read it, bec
|
|||||||
|
|
||||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||||
|
|
||||||
**Eviction**
|
|
||||||
|
|
||||||
An entry nothing has read or written for `retention` is removed, and a repository past `repo_size_limit` loses its least recently accessed entries until it fits; `size_limit` caps the whole cache the same way. Age alone never retires an entry still in use, and whatever these allow, the cache keeps free space above `health_check.min_free_disk_space_mb` when health checks are enabled.
|
|
||||||
|
|
||||||
These apply where the cache server runs, so on a shared server they belong in *its* config, not the runners'. See `retention`, `repo_size_limit`, `size_limit` and `sweep_interval` in [config.example.yaml](internal/pkg/config/config.example.yaml) for units and defaults.
|
|
||||||
|
|
||||||
**Cache service v2**
|
**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:
|
`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:
|
||||||
@@ -314,7 +282,7 @@ cache:
|
|||||||
v2: false
|
v2: false
|
||||||
```
|
```
|
||||||
|
|
||||||
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle on its way into the job, undone whenever the action is downloaded again. A bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork, so it is made whatever `v2` says: that setting only governs the API the runner advertises. Set `runner.patch_actions: false` to leave every bundle exactly as shipped, an escape hatch for an action the edit breaks. The artifact actions then refuse again and the cache client keeps to v1.
|
Those actions refuse any host they do not take for GitHub. 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**
|
**Shared cache across multiple runners**
|
||||||
|
|
||||||
@@ -345,8 +313,6 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
|
|||||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||||
```
|
```
|
||||||
|
|
||||||
Jobs reach the cache server at `external_server`, so when a reverse proxy fronts the server, point `external_server` at the proxy. The cache server itself needs no extra configuration.
|
|
||||||
|
|
||||||
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
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.
|
**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.
|
||||||
@@ -386,16 +352,6 @@ Both hooks are synchronous and block the job while they run. Either one exiting
|
|||||||
|
|
||||||
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||||
|
|
||||||
#### Local job logs (`log.job.dir`)
|
|
||||||
|
|
||||||
Set `log.job.dir` to a path and the runner writes a copy of every task's log there as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same secrets masked and the job's result on the last line. Off by default, and what Gitea shows does not change.
|
|
||||||
|
|
||||||
#### Secret masking
|
|
||||||
|
|
||||||
A job's secrets and its `::add-mask::` values are hidden from what the runner writes and uploads: the job log, the local copy above, job summaries, and the names of the containers it creates. A job output carrying one is skipped with a warning rather than sent masked, as GitHub does, so a downstream `needs.<job>.outputs.<name>` reading it is empty.
|
|
||||||
|
|
||||||
`log.job.retention` (default `168h`) is how long a log is kept, expired ones being deleted as new tasks start, and `log.job.max_size` (default `1GB`) caps one log. Keep `retention` above `runner.timeout` so a long job cannot outlive its own log, and prefer local disk, the file is written while the job runs. Only the runner's own user can read it.
|
|
||||||
|
|
||||||
### Example Deployments
|
### Example Deployments
|
||||||
|
|
||||||
Check out the [examples](examples) directory for sample deployment types.
|
Check out the [examples](examples) directory for sample deployment types.
|
||||||
|
|||||||
+103
-293
@@ -5,13 +5,12 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json/v2"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -21,7 +20,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -29,7 +27,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/internal/pkg/disk"
|
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@@ -62,9 +59,6 @@ type JobCredential struct {
|
|||||||
// remote runner registers with.
|
// remote runner registers with.
|
||||||
Results string `json:"results"`
|
Results string `json:"results"`
|
||||||
InsecureTLS bool `json:"insecure_tls"`
|
InsecureTLS bool `json:"insecure_tls"`
|
||||||
|
|
||||||
// PublicURL is this server as a reverse proxy makes the job reach it, not the listen address.
|
|
||||||
PublicURL string `json:"public_url"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// credEntry holds a registered job's credential along with an active
|
// credEntry holds a registered job's credential along with an active
|
||||||
@@ -106,38 +100,19 @@ type Handler struct {
|
|||||||
|
|
||||||
credMu sync.RWMutex
|
credMu sync.RWMutex
|
||||||
creds map[string]*credEntry
|
creds map[string]*credEntry
|
||||||
|
|
||||||
policy Policy
|
|
||||||
|
|
||||||
// freeDisk is a field so tests can drive evictForFreeSpace without a full volume.
|
|
||||||
freeDisk func(string) (uint64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options configures a cache server started by StartHandler; the zero value is usable.
|
|
||||||
type Options struct {
|
|
||||||
Dir string
|
|
||||||
OutboundIP string
|
|
||||||
Port uint16
|
|
||||||
|
|
||||||
// InternalSecret, when non-empty, enables a control-plane API at
|
|
||||||
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
|
||||||
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
|
|
||||||
// embedded in-process handler leaves it empty and registers tokens via the
|
|
||||||
// in-process RegisterJob method directly.
|
|
||||||
InternalSecret string
|
|
||||||
|
|
||||||
Policy Policy
|
|
||||||
Logger logrus.FieldLogger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartHandler opens the on-disk cache store and starts the HTTP server.
|
// StartHandler opens the on-disk cache store and starts the HTTP server.
|
||||||
func StartHandler(opts Options) (*Handler, error) {
|
//
|
||||||
dir, logger := opts.Dir, opts.Logger
|
// internalSecret, when non-empty, enables a control-plane API at
|
||||||
|
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
||||||
|
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
|
||||||
|
// embedded in-process handler leaves it empty and registers tokens via the
|
||||||
|
// in-process RegisterJob method directly.
|
||||||
|
func StartHandler(dir, outboundIP string, port uint16, internalSecret string, logger logrus.FieldLogger) (*Handler, error) {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
creds: make(map[string]*credEntry),
|
creds: make(map[string]*credEntry),
|
||||||
internalSecret: opts.InternalSecret,
|
internalSecret: internalSecret,
|
||||||
policy: opts.Policy.withDefaults(),
|
|
||||||
freeDisk: disk.FreeBytes,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
@@ -167,8 +142,8 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
}
|
}
|
||||||
h.storage = storage
|
h.storage = storage
|
||||||
|
|
||||||
if opts.OutboundIP != "" {
|
if outboundIP != "" {
|
||||||
h.outboundIP = opts.OutboundIP
|
h.outboundIP = outboundIP
|
||||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
} else if ip := common.GetOutboundIP(); ip == nil {
|
||||||
return nil, errors.New("unable to determine outbound IP address")
|
return nil, errors.New("unable to determine outbound IP address")
|
||||||
} else {
|
} else {
|
||||||
@@ -207,7 +182,7 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
// can break Docker Desktop variants where the host's outbound IP is not
|
// can break Docker Desktop variants where the host's outbound IP is not
|
||||||
// routable from inside the container network. Authentication is enforced
|
// routable from inside the container network. Authentication is enforced
|
||||||
// by the bearer middleware and per-repo scoping, not by reachability.
|
// by the bearer middleware and per-repo scoping, not by reachability.
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", opts.Port))
|
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -237,13 +212,6 @@ func (h *Handler) ExternalURL() string {
|
|||||||
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) baseURL(cred JobCredential) string {
|
|
||||||
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
return h.ExternalURL()
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterJob makes token a valid bearer credential for cache requests from
|
// RegisterJob makes token a valid bearer credential for cache requests from
|
||||||
// the given repository and returns a function that removes it. The runner
|
// the given repository and returns a function that removes it. The runner
|
||||||
// calls this at job start and defers the returned func so that the credential
|
// calls this at job start and defers the returned func so that the credential
|
||||||
@@ -357,8 +325,8 @@ func (h *Handler) Close() error {
|
|||||||
|
|
||||||
func (h *Handler) openDB() (*bolthold.Store, error) {
|
func (h *Handler) openDB() (*bolthold.Store, error) {
|
||||||
return bolthold.Open(filepath.Join(h.dir, "bolt.db"), 0o644, &bolthold.Options{
|
return bolthold.Open(filepath.Join(h.dir, "bolt.db"), 0o644, &bolthold.Options{
|
||||||
Encoder: func(value any) ([]byte, error) { return json.Marshal(value) },
|
Encoder: json.Marshal,
|
||||||
Decoder: func(data []byte, value any) error { return json.Unmarshal(data, value) },
|
Decoder: json.Unmarshal,
|
||||||
Options: &bbolt.Options{
|
Options: &bbolt.Options{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
NoGrowSync: bbolt.DefaultOptions.NoGrowSync,
|
NoGrowSync: bbolt.DefaultOptions.NoGrowSync,
|
||||||
@@ -391,7 +359,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
|||||||
}
|
}
|
||||||
h.responseJSON(w, r, 200, map[string]any{
|
h.responseJSON(w, r, 200, map[string]any{
|
||||||
"result": "hit",
|
"result": "hit",
|
||||||
"archiveLocation": h.signedArtifactURL(cred, cache.ID, time.Now().Add(artifactURLTTL)),
|
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
"cacheKey": cache.Key,
|
"cacheKey": cache.Key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -412,9 +380,6 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
|
|||||||
_ = db.Delete(cache.ID, cache)
|
_ = db.Delete(cache.ID, cache)
|
||||||
return nil, nil //nolint:nilnil // absence is not an error here
|
return nil, nil //nolint:nilnil // absence is not an error here
|
||||||
}
|
}
|
||||||
// Handing out a download URL counts as access, or eviction could drop the entry between
|
|
||||||
// this call and the GET that follows it.
|
|
||||||
h.touch(db, cache)
|
|
||||||
return cache, nil
|
return cache, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,15 +387,13 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
|
|||||||
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
api := &Request{}
|
api := &Request{}
|
||||||
if err := json.UnmarshalRead(r.Body, api); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &Cache{Repo: cred.Repo, Key: api.Key, Version: api.Version, Size: api.Size}
|
cache := api.ToCache()
|
||||||
if cache.Size == 0 {
|
cache.Repo = cred.Repo
|
||||||
cache.Size = -1
|
|
||||||
}
|
|
||||||
db, err := h.openDB()
|
db, err := h.openDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
@@ -554,22 +517,13 @@ func (h *Handler) commitCache(cache *Cache) error {
|
|||||||
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
|
// 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.Size = written
|
||||||
cache.Complete = true
|
cache.Complete = true
|
||||||
cache.UsedAt = time.Now().Unix() // a just-written entry counts as accessed, so it cannot be its own eviction victim
|
|
||||||
|
|
||||||
db, err := h.openDB()
|
db, err := h.openDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
if err := db.Update(cache.ID, cache); err != nil {
|
return db.Update(cache.ID, cache)
|
||||||
return err
|
|
||||||
}
|
|
||||||
// A commit is the only thing that grows the store, so the only thing that can push the
|
|
||||||
// volume under the floor.
|
|
||||||
h.evictRepo(db, cache.Repo)
|
|
||||||
h.evictTotal(db)
|
|
||||||
h.evictForFreeSpace(db)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /_apis/artifactcache/artifacts/:id
|
// GET /_apis/artifactcache/artifacts/:id
|
||||||
@@ -687,12 +641,12 @@ func (h *Handler) ResultsURL(cred JobCredential) string {
|
|||||||
if h == nil || cred.Results == "" {
|
if h == nil || cred.Results == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return h.baseURL(cred)
|
return h.ExternalURL()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
var body internalRegisterBody
|
var body internalRegisterBody
|
||||||
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -708,7 +662,7 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
|
|||||||
// POST /_internal/revoke
|
// POST /_internal/revoke
|
||||||
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
var body internalRevokeBody
|
var body internalRevokeBody
|
||||||
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -746,16 +700,16 @@ func (h *Handler) computeSignature(purpose string, cacheID, exp int64) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
|
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
|
||||||
func (h *Handler) signedURL(cred JobCredential, path, purpose string, cacheID uint64, exp time.Time) string {
|
func (h *Handler) signedURL(path, purpose string, cacheID uint64, exp time.Time) string {
|
||||||
expUnix := exp.Unix()
|
expUnix := exp.Unix()
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
||||||
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
|
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
|
||||||
return fmt.Sprintf("%s%s/%d?%s", h.baseURL(cred), path, cacheID, q.Encode())
|
return fmt.Sprintf("%s%s/%d?%s", h.ExternalURL(), path, cacheID, q.Encode())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) signedArtifactURL(cred JobCredential, cacheID uint64, exp time.Time) string {
|
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
||||||
return h.signedURL(cred, apiPath+"/artifacts", "", cacheID, exp)
|
return h.signedURL(apiPath+"/artifacts", "", cacheID, exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if not found, return (nil, nil) instead of an error.
|
// if not found, return (nil, nil) instead of an error.
|
||||||
@@ -857,43 +811,12 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
miB = 1024 * 1024
|
keepUsed = 30 * 24 * time.Hour
|
||||||
|
keepUnused = 7 * 24 * time.Hour
|
||||||
defaultSweepInterval = time.Hour
|
keepTemp = 5 * time.Minute
|
||||||
|
keepOld = 5 * time.Minute
|
||||||
// inUseGrace matches artifactURLTTL so an entry outlives every signed URL still usable
|
|
||||||
// for it, and no sweep cuts off a download in progress.
|
|
||||||
inUseGrace = artifactURLTTL
|
|
||||||
|
|
||||||
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts
|
|
||||||
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize
|
|
||||||
// a stale reservation.
|
|
||||||
uploadStallTimeout = 5 * time.Minute
|
|
||||||
|
|
||||||
defaultMinFreeDisk = 1024 * miB
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Policy bounds what the cache server keeps: a retention window counted from last access,
|
|
||||||
// and size limits that evict least recently accessed first. A zero limit is no limit.
|
|
||||||
type Policy struct {
|
|
||||||
Retention time.Duration // Retention removes entries nothing has read or written within this window. Zero keeps them regardless of age.
|
|
||||||
RepoSizeLimit int64 // RepoSizeLimit caps one repository's completed entries in bytes, evicting least recently accessed first.
|
|
||||||
SizeLimit int64 // SizeLimit caps every repository's completed entries together, in bytes.
|
|
||||||
SweepInterval time.Duration // SweepInterval is the minimum time between two eviction sweeps.
|
|
||||||
MinFreeDisk int64 // MinFreeDisk is volume headroom the cache will not eat into. Tracks the runner's health-check floor rather than taking a key of its own.
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Policy) withDefaults() Policy {
|
|
||||||
// The limits default in config.LoadDefault, so a written 0 means off.
|
|
||||||
if p.MinFreeDisk <= 0 {
|
|
||||||
p.MinFreeDisk = defaultMinFreeDisk
|
|
||||||
}
|
|
||||||
if p.SweepInterval <= 0 {
|
|
||||||
p.SweepInterval = defaultSweepInterval
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) gcCache() {
|
func (h *Handler) gcCache() {
|
||||||
if h.gcing.Load() {
|
if h.gcing.Load() {
|
||||||
return
|
return
|
||||||
@@ -903,7 +826,7 @@ func (h *Handler) gcCache() {
|
|||||||
}
|
}
|
||||||
defer h.gcing.Store(false)
|
defer h.gcing.Store(false)
|
||||||
|
|
||||||
if time.Since(h.gcAt) < h.policy.SweepInterval {
|
if time.Since(h.gcAt) < time.Hour {
|
||||||
h.logger.Debugf("skip gc: %v", h.gcAt.String())
|
h.logger.Debugf("skip gc: %v", h.gcAt.String())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -916,60 +839,71 @@ func (h *Handler) gcCache() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
h.evictIncomplete(db)
|
// Remove the caches which are not completed for a while, they are most likely to be broken.
|
||||||
h.evictExpired(db)
|
|
||||||
h.evictSuperseded(db)
|
|
||||||
h.evictOversized(db)
|
|
||||||
h.evictForFreeSpace(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictForFreeSpace bounds the volume itself, so it also covers bytes the cache never
|
|
||||||
// accounted for.
|
|
||||||
func (h *Handler) evictForFreeSpace(db *bolthold.Store) {
|
|
||||||
free, err := h.freeDisk(h.dir)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Debugf("free disk check: %v", err) // unsupported platform, treat as unavailable rather than full
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if free >= uint64(h.policy.MinFreeDisk) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
caches := h.completedByUse(db)
|
|
||||||
total, shortfall := totalSize(caches), h.policy.MinFreeDisk-int64(free)
|
|
||||||
if total <= shortfall {
|
|
||||||
// Say so, or shedding everything and still being short reads as the backstop working.
|
|
||||||
h.logger.Warnf("cache volume is %d MiB short of the free space floor with only %d MiB of cache on it; something else is filling it", shortfall/miB, total/miB)
|
|
||||||
}
|
|
||||||
h.evictTo(db, caches, total-shortfall, "the cache volume")
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictIncomplete removes uploads that stopped part way, which are most likely broken.
|
|
||||||
func (h *Handler) evictIncomplete(db *bolthold.Store) {
|
|
||||||
h.sweep(db, bolthold.
|
|
||||||
Where("UsedAt").Lt(time.Now().Add(-uploadStallTimeout).Unix()).
|
|
||||||
And("Complete").Eq(false).
|
|
||||||
Index("UsedAt"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) evictExpired(db *bolthold.Store) {
|
|
||||||
if h.policy.Retention <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
|
|
||||||
window := max(h.policy.Retention, inUseGrace)
|
|
||||||
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictSuperseded removes entries a newer one with the same key and version replaced. The
|
|
||||||
// aggregation includes Repo so two repos sharing a (key, version) do not evict each other.
|
|
||||||
func (h *Handler) evictSuperseded(db *bolthold.Store) {
|
|
||||||
results, err := db.FindAggregate(&Cache{}, bolthold.Where("Complete").Eq(true).Index("Complete"), "Repo", "Key", "Version")
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Warnf("find aggregate caches: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var caches []*Cache
|
var caches []*Cache
|
||||||
|
if err := db.Find(&caches, bolthold.
|
||||||
|
Where("UsedAt").Lt(time.Now().Add(-keepTemp).Unix()).
|
||||||
|
And("Complete").Eq(false),
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Warnf("find caches: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, cache := range caches {
|
||||||
|
h.storage.Remove(cache.ID)
|
||||||
|
if err := db.Delete(cache.ID, cache); err != nil {
|
||||||
|
h.logger.Warnf("delete cache: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.logger.Infof("deleted cache: %+v", cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the old caches which have not been used recently.
|
||||||
|
caches = caches[:0]
|
||||||
|
if err := db.Find(&caches, bolthold.
|
||||||
|
Where("UsedAt").Lt(time.Now().Add(-keepUnused).Unix()),
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Warnf("find caches: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, cache := range caches {
|
||||||
|
h.storage.Remove(cache.ID)
|
||||||
|
if err := db.Delete(cache.ID, cache); err != nil {
|
||||||
|
h.logger.Warnf("delete cache: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.logger.Infof("deleted cache: %+v", cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the old caches which are too old.
|
||||||
|
caches = caches[:0]
|
||||||
|
if err := db.Find(&caches, bolthold.
|
||||||
|
Where("CreatedAt").Lt(time.Now().Add(-keepUsed).Unix()),
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Warnf("find caches: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, cache := range caches {
|
||||||
|
h.storage.Remove(cache.ID)
|
||||||
|
if err := db.Delete(cache.ID, cache); err != nil {
|
||||||
|
h.logger.Warnf("delete cache: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.logger.Infof("deleted cache: %+v", cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the old caches with the same key and version within the same
|
||||||
|
// repository, keep the latest one. Aggregation must include Repo so two
|
||||||
|
// repos that happen to share a (key, version) do not evict each other —
|
||||||
|
// otherwise per-repo scoping holds for reads but one repo can age
|
||||||
|
// another out after keepOld.
|
||||||
|
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
|
||||||
|
if results, err := db.FindAggregate(
|
||||||
|
&Cache{},
|
||||||
|
bolthold.Where("Complete").Eq(true),
|
||||||
|
"Repo", "Key", "Version",
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Warnf("find aggregate caches: %v", err)
|
||||||
|
} else {
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
if result.Count() <= 1 {
|
if result.Count() <= 1 {
|
||||||
continue
|
continue
|
||||||
@@ -978,144 +912,20 @@ func (h *Handler) evictSuperseded(db *bolthold.Store) {
|
|||||||
caches = caches[:0]
|
caches = caches[:0]
|
||||||
result.Reduction(&caches)
|
result.Reduction(&caches)
|
||||||
for _, cache := range caches[:len(caches)-1] {
|
for _, cache := range caches[:len(caches)-1] {
|
||||||
if inUse(cache) {
|
if time.Since(time.Unix(cache.UsedAt, 0)) < keepOld {
|
||||||
|
// Keep it since it has been used recently, even if it's old.
|
||||||
|
// Or it could break downloading in process.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
h.deleteCache(db, cache)
|
h.storage.Remove(cache.ID)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictOversized applies the per-repository limit, then the whole-store one. Only completed
|
|
||||||
// entries count, since only those carry a size measured at commit rather than claimed.
|
|
||||||
func (h *Handler) evictOversized(db *bolthold.Store) {
|
|
||||||
if h.policy.RepoSizeLimit > 0 {
|
|
||||||
byRepo := make(map[string][]*Cache)
|
|
||||||
for _, cache := range h.completedByUse(db) {
|
|
||||||
byRepo[cache.Repo] = append(byRepo[cache.Repo], cache)
|
|
||||||
}
|
|
||||||
for repo, caches := range byRepo {
|
|
||||||
h.evictTo(db, caches, h.policy.RepoSizeLimit, "repository "+repo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.evictTotal(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictTotal caps the store as a whole. It re-queries because the per-repo pass may have
|
|
||||||
// deleted rows an earlier result still holds.
|
|
||||||
func (h *Handler) evictTotal(db *bolthold.Store) {
|
|
||||||
if h.policy.SizeLimit <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.evictTo(db, h.completedByUse(db), h.policy.SizeLimit, "the cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictRepo reclaims space when a commit pushes a repo over, rather than at the next sweep.
|
|
||||||
func (h *Handler) evictRepo(db *bolthold.Store, repo string) {
|
|
||||||
if h.policy.RepoSizeLimit <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.evictTo(db, h.cachesByUse(db, bolthold.Where("Repo").Eq(repo).And("Complete").Eq(true).Index("Repo")), h.policy.RepoSizeLimit, "repository "+repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictTo deletes until caches fit limit. caches must be ordered by UsedAt ascending.
|
|
||||||
func (h *Handler) evictTo(db *bolthold.Store, caches []*Cache, limit int64, scope string) {
|
|
||||||
// An entry bigger than the limit never fits, so it goes on its own account instead of
|
|
||||||
// dragging every neighbour out first and then following them next sweep.
|
|
||||||
fits := caches[:0]
|
|
||||||
for _, cache := range caches {
|
|
||||||
if cache.Size <= limit {
|
|
||||||
fits = append(fits, cache)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !inUse(cache) {
|
|
||||||
h.logger.Warnf("cache %q is %d MiB on its own, over the limit for %s; dropping it", cache.Key, cache.Size/miB, scope)
|
|
||||||
h.deleteCache(db, cache)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
caches = fits
|
|
||||||
|
|
||||||
total := totalSize(caches)
|
|
||||||
var freed int64
|
|
||||||
for _, cache := range caches {
|
|
||||||
if total <= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if inUse(cache) || !h.deleteCache(db, cache) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
total -= cache.Size
|
|
||||||
freed += cache.Size
|
|
||||||
}
|
|
||||||
if freed > 0 {
|
|
||||||
h.logger.Warnf("evicted %d MiB from %s, least recently used first", freed/miB, scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// inUse reports whether an entry was read or written recently enough that removing it
|
|
||||||
// could break a download in progress.
|
|
||||||
func inUse(cache *Cache) bool {
|
|
||||||
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
|
|
||||||
}
|
|
||||||
|
|
||||||
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
|
|
||||||
// go through touchCache, which opens its own store and would block on the exclusive lock
|
|
||||||
// for as long as the caller holds one.
|
|
||||||
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
|
|
||||||
cache.UsedAt = time.Now().Unix()
|
|
||||||
if err := db.Update(cache.ID, cache); err != nil {
|
|
||||||
h.logger.Warnf("touch cache: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) sweep(db *bolthold.Store, query *bolthold.Query) {
|
|
||||||
for _, cache := range h.caches(db, query) {
|
|
||||||
h.deleteCache(db, cache)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) caches(db *bolthold.Store, query *bolthold.Query) []*Cache {
|
|
||||||
var caches []*Cache
|
|
||||||
if err := db.Find(&caches, query); err != nil {
|
|
||||||
h.logger.Warnf("find caches: %v", err)
|
|
||||||
}
|
|
||||||
return caches
|
|
||||||
}
|
|
||||||
|
|
||||||
// cachesByUse returns matches least recently accessed first, sorting here rather than with
|
|
||||||
// bolthold's SortBy, which reflects over every field it compares.
|
|
||||||
func (h *Handler) cachesByUse(db *bolthold.Store, query *bolthold.Query) []*Cache {
|
|
||||||
caches := h.caches(db, query)
|
|
||||||
slices.SortFunc(caches, func(a, b *Cache) int { return cmp.Compare(a.UsedAt, b.UsedAt) })
|
|
||||||
return caches
|
|
||||||
}
|
|
||||||
|
|
||||||
// completedByUse returns every entry the size limits count, least recently accessed first.
|
|
||||||
func (h *Handler) completedByUse(db *bolthold.Store) []*Cache {
|
|
||||||
return h.cachesByUse(db, bolthold.Where("Complete").Eq(true).Index("Complete"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func totalSize(caches []*Cache) int64 {
|
|
||||||
var total int64
|
|
||||||
for _, cache := range caches {
|
|
||||||
total += cache.Size
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes
|
|
||||||
// first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes.
|
|
||||||
func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool {
|
|
||||||
if err := h.storage.Remove(cache.ID); err != nil {
|
|
||||||
h.logger.Warnf("remove cache blob: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := db.Delete(cache.ID, cache); err != nil {
|
if err := db.Delete(cache.ID, cache); err != nil {
|
||||||
h.logger.Warnf("delete cache: %v", err)
|
h.logger.Warnf("delete cache: %v", err)
|
||||||
return false
|
continue
|
||||||
}
|
}
|
||||||
h.logger.Infof("deleted cache: %+v", cache)
|
h.logger.Infof("deleted cache: %+v", cache)
|
||||||
return true
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any) {
|
func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int, v ...any) {
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ package artifactcache
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/json/v2"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -42,19 +41,16 @@ func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
var testClient = &http.Client{Transport: &bearerTransport{token: testToken}}
|
var testClient = &http.Client{Transport: &bearerTransport{token: testToken}}
|
||||||
|
|
||||||
// testRetention mirrors config.DefaultCacheRetention; Policy has no defaults of its own.
|
|
||||||
const testRetention = 7 * 24 * time.Hour
|
|
||||||
|
|
||||||
// signArtifactURL builds a signed download URL the same way the server does;
|
// signArtifactURL builds a signed download URL the same way the server does;
|
||||||
// tests use it to reach the get handler directly without going through a
|
// tests use it to reach the get handler directly without going through a
|
||||||
// find/cache-hit round trip.
|
// find/cache-hit round trip.
|
||||||
func signArtifactURL(h *Handler, id int64) string {
|
func signArtifactURL(h *Handler, id int64) string {
|
||||||
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
|
return h.signedArtifactURL(uint64(id), time.Now().Add(artifactURLTTL))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler(t *testing.T) {
|
func TestHandler(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
|
||||||
@@ -136,7 +132,7 @@ func TestHandler(t *testing.T) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &first))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&first))
|
||||||
assert.NotZero(t, first.CacheID)
|
assert.NotZero(t, first.CacheID)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -151,7 +147,7 @@ func TestHandler(t *testing.T) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &second))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&second))
|
||||||
assert.NotZero(t, second.CacheID)
|
assert.NotZero(t, second.CacheID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +200,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -259,7 +255,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -315,7 +311,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -362,7 +358,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +409,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -493,7 +489,7 @@ func TestHandler(t *testing.T) {
|
|||||||
ArchiveLocation string `json:"archiveLocation"`
|
ArchiveLocation string `json:"archiveLocation"`
|
||||||
CacheKey string `json:"cacheKey"`
|
CacheKey string `json:"cacheKey"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, "hit", got.Result)
|
assert.Equal(t, "hit", got.Result)
|
||||||
assert.Equal(t, keys[except], got.CacheKey)
|
assert.Equal(t, keys[except], got.CacheKey)
|
||||||
|
|
||||||
@@ -528,7 +524,7 @@ func TestHandler(t *testing.T) {
|
|||||||
ArchiveLocation string `json:"archiveLocation"`
|
ArchiveLocation string `json:"archiveLocation"`
|
||||||
CacheKey string `json:"cacheKey"`
|
CacheKey string `json:"cacheKey"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, "hit", got.Result)
|
assert.Equal(t, "hit", got.Result)
|
||||||
assert.Equal(t, key, got.CacheKey)
|
assert.Equal(t, key, got.CacheKey)
|
||||||
assert.NotEqual(t, strings.ToLower(key), got.CacheKey)
|
assert.NotEqual(t, strings.ToLower(key), got.CacheKey)
|
||||||
@@ -577,7 +573,7 @@ func TestHandler(t *testing.T) {
|
|||||||
ArchiveLocation string `json:"archiveLocation"`
|
ArchiveLocation string `json:"archiveLocation"`
|
||||||
CacheKey string `json:"cacheKey"`
|
CacheKey string `json:"cacheKey"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, keys[expect], got.CacheKey)
|
assert.Equal(t, keys[expect], got.CacheKey)
|
||||||
|
|
||||||
contentResp, err := testClient.Get(got.ArchiveLocation)
|
contentResp, err := testClient.Get(got.ArchiveLocation)
|
||||||
@@ -633,7 +629,7 @@ func TestHandler(t *testing.T) {
|
|||||||
ArchiveLocation string `json:"archiveLocation"`
|
ArchiveLocation string `json:"archiveLocation"`
|
||||||
CacheKey string `json:"cacheKey"`
|
CacheKey string `json:"cacheKey"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, keys[expect], got.CacheKey)
|
assert.Equal(t, keys[expect], got.CacheKey)
|
||||||
|
|
||||||
contentResp, err := testClient.Get(got.ArchiveLocation)
|
contentResp, err := testClient.Get(got.ArchiveLocation)
|
||||||
@@ -660,7 +656,7 @@ func backdateCache(t *testing.T, handler *Handler, key string, age time.Duration
|
|||||||
require.NoError(t, db.Update(caches[0].ID, caches[0]))
|
require.NoError(t, db.Update(caches[0].ID, caches[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) {
|
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
var id uint64
|
var id uint64
|
||||||
{
|
{
|
||||||
body, err := json.Marshal(&Request{
|
body, err := json.Marshal(&Request{
|
||||||
@@ -677,7 +673,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -708,7 +704,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
|||||||
ArchiveLocation string `json:"archiveLocation"`
|
ArchiveLocation string `json:"archiveLocation"`
|
||||||
CacheKey string `json:"cacheKey"`
|
CacheKey string `json:"cacheKey"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, "hit", got.Result)
|
assert.Equal(t, "hit", got.Result)
|
||||||
assert.Equal(t, key, got.CacheKey)
|
assert.Equal(t, key, got.CacheKey)
|
||||||
archiveLocation = got.ArchiveLocation
|
archiveLocation = got.ArchiveLocation
|
||||||
@@ -726,7 +722,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
|||||||
|
|
||||||
func TestHandler_gcCache(t *testing.T) {
|
func TestHandler_gcCache(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir, Policy: Policy{Retention: testRetention}})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -756,8 +752,8 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_2",
|
Key: "test_key_2",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: false,
|
Complete: false,
|
||||||
UsedAt: now.Add(-(inUseGrace + time.Second)).Unix(),
|
UsedAt: now.Add(-(keepTemp + time.Second)).Unix(),
|
||||||
CreatedAt: now.Add(-(inUseGrace + time.Hour)).Unix(),
|
CreatedAt: now.Add(-(keepTemp + time.Hour)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: false,
|
||||||
},
|
},
|
||||||
@@ -767,21 +763,21 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_3",
|
Key: "test_key_3",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Add(-(testRetention + time.Second)).Unix(),
|
UsedAt: now.Add(-(keepUnused + time.Second)).Unix(),
|
||||||
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
CreatedAt: now.Add(-(keepUnused + time.Hour)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// should be kept, since age alone does not retire an entry that is still used.
|
// should be removed, since it's used but too old.
|
||||||
Cache: &Cache{
|
Cache: &Cache{
|
||||||
Key: "test_key_3",
|
Key: "test_key_3",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Unix(),
|
UsedAt: now.Unix(),
|
||||||
CreatedAt: now.Add(-365 * 24 * time.Hour).Unix(),
|
CreatedAt: now.Add(-(keepUsed + time.Second)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: true,
|
Kept: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// should be kept, since it has a newer edition but be used recently.
|
// should be kept, since it has a newer edition but be used recently.
|
||||||
@@ -789,7 +785,7 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_1",
|
Key: "test_key_1",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Add(-(inUseGrace - time.Minute)).Unix(),
|
UsedAt: now.Add(-(keepOld - time.Minute)).Unix(),
|
||||||
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
|
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: true,
|
Kept: true,
|
||||||
@@ -800,7 +796,7 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_1",
|
Key: "test_key_1",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Add(-(inUseGrace + time.Second)).Unix(),
|
UsedAt: now.Add(-(keepOld + time.Second)).Unix(),
|
||||||
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
|
CreatedAt: now.Add(-(time.Hour + time.Second)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: false,
|
||||||
@@ -833,265 +829,11 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandler_evictPolicy covers the non-default policies; TestHandler_gcCache covers the
|
|
||||||
// defaults across every pass.
|
|
||||||
func TestHandler_evictPolicy(t *testing.T) {
|
|
||||||
now := time.Now()
|
|
||||||
stale := func(d time.Duration) int64 { return now.Add(-d).Unix() }
|
|
||||||
mib := func(n int64) int64 { return n * miB }
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
policy Policy
|
|
||||||
entries []*Cache
|
|
||||||
kept []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "a zero retention keeps an entry nothing has touched",
|
|
||||||
policy: Policy{Retention: 0},
|
|
||||||
entries: []*Cache{
|
|
||||||
{Key: "idle", UsedAt: stale(testRetention + time.Hour)},
|
|
||||||
},
|
|
||||||
kept: []string{"idle"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "evicts least recently accessed until the repository fits",
|
|
||||||
policy: Policy{RepoSizeLimit: mib(10)},
|
|
||||||
entries: []*Cache{
|
|
||||||
{Repo: "o/a", Key: "oldest", Size: mib(4), UsedAt: stale(3 * time.Hour)},
|
|
||||||
{Repo: "o/a", Key: "middle", Size: mib(4), UsedAt: stale(2 * time.Hour)},
|
|
||||||
{Repo: "o/a", Key: "newest", Size: mib(4), UsedAt: stale(time.Hour)},
|
|
||||||
},
|
|
||||||
kept: []string{"middle", "newest"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "spares entries that may still be downloading",
|
|
||||||
policy: Policy{RepoSizeLimit: mib(10)},
|
|
||||||
entries: []*Cache{
|
|
||||||
{Repo: "o/a", Key: "fresh_1", Size: mib(6), UsedAt: stale(time.Minute)},
|
|
||||||
{Repo: "o/a", Key: "fresh_2", Size: mib(6), UsedAt: stale(time.Minute)},
|
|
||||||
},
|
|
||||||
kept: []string{"fresh_1", "fresh_2"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "one repository over its limit leaves another alone",
|
|
||||||
policy: Policy{RepoSizeLimit: mib(10)},
|
|
||||||
entries: []*Cache{
|
|
||||||
{Repo: "o/a", Key: "a_old", Size: mib(6), UsedAt: stale(3 * time.Hour)},
|
|
||||||
{Repo: "o/a", Key: "a_new", Size: mib(6), UsedAt: stale(time.Hour)},
|
|
||||||
{Repo: "o/b", Key: "b_old", Size: mib(6), UsedAt: stale(4 * time.Hour)},
|
|
||||||
},
|
|
||||||
kept: []string{"a_new", "b_old"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "the total limit evicts across repositories once each fits its own",
|
|
||||||
policy: Policy{RepoSizeLimit: mib(10), SizeLimit: mib(12)},
|
|
||||||
entries: []*Cache{
|
|
||||||
{Repo: "o/a", Key: "a_old", Size: mib(8), UsedAt: stale(3 * time.Hour)},
|
|
||||||
{Repo: "o/b", Key: "b_new", Size: mib(8), UsedAt: stale(time.Hour)},
|
|
||||||
},
|
|
||||||
kept: []string{"b_new"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Retention below inUseGrace would otherwise drop an entry whose signed URL a job
|
|
||||||
// is still holding.
|
|
||||||
name: "a retention shorter than the grace still spares a just-served entry",
|
|
||||||
policy: Policy{Retention: time.Minute},
|
|
||||||
entries: []*Cache{
|
|
||||||
{Key: "just_served", UsedAt: stale(2 * time.Minute)},
|
|
||||||
{Key: "idle", UsedAt: stale(time.Hour)},
|
|
||||||
},
|
|
||||||
kept: []string{"just_served"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "an entry over the limit goes without emptying the repository",
|
|
||||||
policy: Policy{RepoSizeLimit: mib(10)},
|
|
||||||
entries: []*Cache{
|
|
||||||
{Repo: "o/a", Key: "keeps", Size: mib(4), UsedAt: stale(3 * time.Hour)},
|
|
||||||
{Repo: "o/a", Key: "huge", Size: mib(20), UsedAt: stale(2 * time.Hour)},
|
|
||||||
},
|
|
||||||
kept: []string{"keeps"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "a zero limit keeps everything",
|
|
||||||
policy: Policy{RepoSizeLimit: 0},
|
|
||||||
entries: []*Cache{
|
|
||||||
{Repo: "o/a", Key: "huge_1", Size: mib(100), UsedAt: stale(3 * time.Hour)},
|
|
||||||
{Repo: "o/a", Key: "huge_2", Size: mib(100), UsedAt: stale(2 * time.Hour)},
|
|
||||||
},
|
|
||||||
kept: []string{"huge_1", "huge_2"},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
for _, e := range tc.entries {
|
|
||||||
e.Complete = true // only completed entries carry a measured size, so only they count
|
|
||||||
}
|
|
||||||
handler := newTestHandler(t, tc.policy, tc.entries...)
|
|
||||||
handler.gcAt = time.Time{} // ensure gcCache will not skip
|
|
||||||
handler.gcCache()
|
|
||||||
assert.ElementsMatch(t, tc.kept, keptKeys(t, handler, tc.entries))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_evictForFreeSpace proves the volume backstop sheds only what it must, and only
|
|
||||||
// when the disk is actually short.
|
|
||||||
func TestHandler_evictForFreeSpace(t *testing.T) {
|
|
||||||
free := func(n int64) func(string) (uint64, error) {
|
|
||||||
return func(string) (uint64, error) { return uint64(n), nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
freeDisk func(string) (uint64, error)
|
|
||||||
kept []string
|
|
||||||
}{
|
|
||||||
{"ample free space evicts nothing", free(defaultMinFreeDisk), []string{"oldest", "middle", "newest"}},
|
|
||||||
{"a small shortfall sheds one entry", free(defaultMinFreeDisk - 4*miB), []string{"middle", "newest"}},
|
|
||||||
{"a shortfall the cache cannot cover sheds all of it", free(0), nil},
|
|
||||||
{
|
|
||||||
"an unreadable volume is treated as unavailable, not as full",
|
|
||||||
func(string) (uint64, error) { return 0, errors.New("unsupported") },
|
|
||||||
[]string{"oldest", "middle", "newest"},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
now := time.Now()
|
|
||||||
entries := []*Cache{
|
|
||||||
{Key: "oldest", Complete: true, Size: 4 * miB, UsedAt: now.Add(-3 * time.Hour).Unix()},
|
|
||||||
{Key: "middle", Complete: true, Size: 4 * miB, UsedAt: now.Add(-2 * time.Hour).Unix()},
|
|
||||||
{Key: "newest", Complete: true, Size: 4 * miB, UsedAt: now.Add(-time.Hour).Unix()},
|
|
||||||
}
|
|
||||||
handler := newTestHandler(t, Policy{}, entries...)
|
|
||||||
handler.freeDisk = tc.freeDisk
|
|
||||||
|
|
||||||
db, err := handler.openDB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
handler.evictForFreeSpace(db)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, tc.kept, keptKeys(t, handler, entries))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_SweepKeepsEntryWhenBlobSurvives proves a failed unlink leaves the row in place,
|
|
||||||
// so the next sweep retries rather than orphaning bytes no row points at and no limit counts.
|
|
||||||
func TestHandler_SweepKeepsEntryWhenBlobSurvives(t *testing.T) {
|
|
||||||
cache := &Cache{Key: "stuck", Complete: true, UsedAt: time.Now().Add(-(testRetention + time.Hour)).Unix()}
|
|
||||||
handler := newTestHandler(t, Policy{Retention: testRetention}, cache)
|
|
||||||
|
|
||||||
// A non-empty directory where the blob belongs makes os.Remove fail on every platform.
|
|
||||||
blob := handler.storage.filename(cache.ID)
|
|
||||||
require.NoError(t, os.MkdirAll(blob, 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600))
|
|
||||||
|
|
||||||
handler.gcAt = time.Time{}
|
|
||||||
handler.gcCache()
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"stuck"}, keptKeys(t, handler, []*Cache{cache}), "the entry must outlive a blob that could not be removed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_FindProtectsFromEviction covers the window between a find handing out a signed
|
|
||||||
// download URL and the GET that redeems it: the entry promised to a job must not be the next
|
|
||||||
// eviction victim just because its last access predates the find.
|
|
||||||
func TestHandler_FindProtectsFromEviction(t *testing.T) {
|
|
||||||
// 12 MiB against a 10 MiB limit, so exactly one entry has to go.
|
|
||||||
wanted := &Cache{Repo: testRepo, Key: "wanted", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-3 * time.Hour).Unix()}
|
|
||||||
other := &Cache{Repo: testRepo, Key: "other", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-2 * time.Hour).Unix()}
|
|
||||||
newest := &Cache{Repo: testRepo, Key: "newest", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
|
|
||||||
handler := newTestHandler(t, Policy{RepoSizeLimit: 10 * miB}, wanted, other, newest)
|
|
||||||
writeBlob(t, handler, wanted.ID) // find only reports a hit when the blob is on disk
|
|
||||||
|
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=wanted&version=v", handler.ExternalURL(), apiPath))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 200, resp.StatusCode)
|
|
||||||
|
|
||||||
// Evict directly: the request above kicked off an async gcCache, and writing gcAt here
|
|
||||||
// to drive gcCache would race its read.
|
|
||||||
db, err := handler.openDB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { require.NoError(t, db.Close()) }()
|
|
||||||
handler.evictOversized(db)
|
|
||||||
|
|
||||||
require.NoError(t, db.Get(wanted.ID, &Cache{}), "the entry just promised to a job must survive")
|
|
||||||
assert.ErrorIs(t, db.Get(other.ID, &Cache{}), bolthold.ErrNotFound, "the next least recently used goes instead")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_evictOnCommit proves a repository that goes over its limit gets space back at
|
|
||||||
// once, rather than waiting out the collection interval.
|
|
||||||
func TestHandler_evictOnCommit(t *testing.T) {
|
|
||||||
full := &Cache{Repo: testRepo, Key: "full", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
|
|
||||||
handler := newTestHandler(t, Policy{RepoSizeLimit: 4 * miB}, full)
|
|
||||||
|
|
||||||
// StartHandler already stamped gcAt, so the periodic sweep stays rate-limited out and
|
|
||||||
// only the commit path can evict.
|
|
||||||
uploadCacheNormally(t, handler.ExternalURL()+apiPath, "new", "v", []byte("some content"))
|
|
||||||
|
|
||||||
assert.Empty(t, keptKeys(t, handler, []*Cache{full}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandler_gcCacheInterval(t *testing.T) {
|
|
||||||
cache := &Cache{Key: "temp", UsedAt: time.Now().Add(-time.Hour).Unix()}
|
|
||||||
// Half the default, so a sweep 45m ago is still inside the default but past this one.
|
|
||||||
handler := newTestHandler(t, Policy{SweepInterval: 30 * time.Minute}, cache)
|
|
||||||
|
|
||||||
handler.gcAt = time.Now().Add(-45 * time.Minute) // past the configured interval, still inside the default
|
|
||||||
handler.gcCache()
|
|
||||||
assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestHandler starts a handler with testToken registered, seeded with entries.
|
|
||||||
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
|
||||||
t.Helper()
|
|
||||||
handler, err := StartHandler(Options{
|
|
||||||
Dir: filepath.Join(t.TempDir(), "artifactcache"),
|
|
||||||
OutboundIP: "127.0.0.1",
|
|
||||||
Policy: policy,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { require.NoError(t, handler.Close()) })
|
|
||||||
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
|
||||||
|
|
||||||
db, err := handler.openDB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
for _, e := range entries {
|
|
||||||
require.NoError(t, insertCache(db, e))
|
|
||||||
}
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
return handler
|
|
||||||
}
|
|
||||||
|
|
||||||
// keptKeys reports which of entries are still in the store.
|
|
||||||
func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string {
|
|
||||||
t.Helper()
|
|
||||||
db, err := handler.openDB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { require.NoError(t, db.Close()) }()
|
|
||||||
|
|
||||||
var kept []string
|
|
||||||
for _, e := range entries {
|
|
||||||
if err := db.Get(e.ID, &Cache{}); err == nil {
|
|
||||||
kept = append(kept, e.Key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return kept
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeBlob gives an entry the on-disk bytes that find and get require.
|
|
||||||
func writeBlob(t *testing.T, handler *Handler, id uint64) {
|
|
||||||
t.Helper()
|
|
||||||
require.NoError(t, handler.storage.Write(id, 0, strings.NewReader("a")))
|
|
||||||
_, err := handler.storage.Commit(id, 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_RejectsMissingBearer covers the advisory's root cause:
|
// TestHandler_RejectsMissingBearer covers the advisory's root cause:
|
||||||
// unauthenticated access to management endpoints is now refused with 401.
|
// unauthenticated access to management endpoints is now refused with 401.
|
||||||
func TestHandler_RejectsMissingBearer(t *testing.T) {
|
func TestHandler_RejectsMissingBearer(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
@@ -1124,7 +866,7 @@ func TestHandler_RejectsMissingBearer(t *testing.T) {
|
|||||||
// accepted after RegisterJob; stale/forged tokens cannot be replayed.
|
// accepted after RegisterJob; stale/forged tokens cannot be replayed.
|
||||||
func TestHandler_RejectsUnknownBearer(t *testing.T) {
|
func TestHandler_RejectsUnknownBearer(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
@@ -1144,7 +886,7 @@ func TestHandler_RejectsUnknownBearer(t *testing.T) {
|
|||||||
// working the moment the job ends instead of living for the runner's lifetime.
|
// working the moment the job ends instead of living for the runner's lifetime.
|
||||||
func TestHandler_UnregisterRevokes(t *testing.T) {
|
func TestHandler_UnregisterRevokes(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
@@ -1175,7 +917,7 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
|
|||||||
// invisible to queries scoped to repoB.
|
// invisible to queries scoped to repoB.
|
||||||
func TestHandler_CrossRepoIsolation(t *testing.T) {
|
func TestHandler_CrossRepoIsolation(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
|
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
|
||||||
@@ -1197,7 +939,7 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
|
|||||||
var reserved struct {
|
var reserved struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &reserved))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&reserved))
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
require.NotZero(t, reserved.CacheID)
|
require.NotZero(t, reserved.CacheID)
|
||||||
|
|
||||||
@@ -1241,7 +983,7 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
|
|||||||
// working after artifactURLTTL even if the bearer token is still registered.
|
// working after artifactURLTTL even if the bearer token is still registered.
|
||||||
func TestHandler_ArtifactSignature(t *testing.T) {
|
func TestHandler_ArtifactSignature(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
@@ -1256,7 +998,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("tampered signature", func(t *testing.T) {
|
t.Run("tampered signature", func(t *testing.T) {
|
||||||
good := signArtifactURL(handler, 1)
|
good := handler.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
|
||||||
bad := good[:len(good)-4] + "dead"
|
bad := good[:len(good)-4] + "dead"
|
||||||
resp, err := testClient.Get(bad)
|
resp, err := testClient.Get(bad)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -1265,7 +1007,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("expired signature", func(t *testing.T) {
|
t.Run("expired signature", func(t *testing.T) {
|
||||||
expired := handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))
|
expired := handler.signedArtifactURL(1, time.Now().Add(-time.Second))
|
||||||
resp, err := testClient.Get(expired)
|
resp, err := testClient.Get(expired)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -1274,10 +1016,10 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("signature from a different server", func(t *testing.T) {
|
t.Run("signature from a different server", func(t *testing.T) {
|
||||||
dir2 := filepath.Join(t.TempDir(), "artifactcache2")
|
dir2 := filepath.Join(t.TempDir(), "artifactcache2")
|
||||||
other, err := StartHandler(Options{Dir: dir2})
|
other, err := StartHandler(dir2, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer other.Close()
|
defer other.Close()
|
||||||
otherURL := signArtifactURL(other, 1)
|
otherURL := other.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
|
||||||
// Rewrite the host so the request still lands on our handler, but
|
// Rewrite the host so the request still lands on our handler, but
|
||||||
// the signature was computed with a different secret.
|
// the signature was computed with a different secret.
|
||||||
parts := strings.SplitN(otherURL, apiPath, 2)
|
parts := strings.SplitN(otherURL, apiPath, 2)
|
||||||
@@ -1296,13 +1038,13 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
|
|
||||||
first, err := StartHandler(Options{Dir: dir, OutboundIP: "127.0.0.1"})
|
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
exp := time.Now().Add(artifactURLTTL).Unix()
|
exp := time.Now().Add(artifactURLTTL).Unix()
|
||||||
sig := first.computeSignature("", 42, exp)
|
sig := first.computeSignature("", 42, exp)
|
||||||
require.NoError(t, first.Close())
|
require.NoError(t, first.Close())
|
||||||
|
|
||||||
second, err := StartHandler(Options{Dir: dir, OutboundIP: "127.0.0.1"})
|
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer second.Close()
|
defer second.Close()
|
||||||
|
|
||||||
@@ -1314,7 +1056,7 @@ func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
|||||||
// the auth refactor.
|
// the auth refactor.
|
||||||
func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
@@ -1331,7 +1073,7 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
|||||||
var hit struct {
|
var hit struct {
|
||||||
ArchiveLocation string `json:"archiveLocation"`
|
ArchiveLocation string `json:"archiveLocation"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &hit))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&hit))
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
require.Contains(t, hit.ArchiveLocation, "sig=")
|
require.Contains(t, hit.ArchiveLocation, "sig=")
|
||||||
@@ -1354,7 +1096,7 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
|||||||
// (restart mid-task, retry), which must not kill the live job's auth.
|
// (restart mid-task, retry), which must not kill the live job's auth.
|
||||||
func TestHandler_RegisterJob_RefCounted(t *testing.T) {
|
func TestHandler_RegisterJob_RefCounted(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
@@ -1383,10 +1125,10 @@ func TestHandler_RegisterJob_RefCounted(t *testing.T) {
|
|||||||
|
|
||||||
// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict
|
// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict
|
||||||
// another repo's entry. Two repos reserve the same (key, version); after the
|
// another repo's entry. Two repos reserve the same (key, version); after the
|
||||||
// inUseGrace window, GC must keep the one from each repo.
|
// keepOld window, GC must keep the one from each repo.
|
||||||
func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob("tok-a", JobCredential{Repo: "owner/repoA"})
|
handler.RegisterJob("tok-a", JobCredential{Repo: "owner/repoA"})
|
||||||
@@ -1400,7 +1142,7 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
|||||||
db, err := handler.openDB()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
|
stale := time.Now().Add(-keepOld - time.Minute).Unix()
|
||||||
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
||||||
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
||||||
require.NoError(t, insertCache(db, a))
|
require.NoError(t, insertCache(db, a))
|
||||||
@@ -1437,7 +1179,7 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
|||||||
// register/revoke when the feature is off.
|
// register/revoke when the feature is off.
|
||||||
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(dir, "", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
@@ -1455,7 +1197,7 @@ func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
|||||||
func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
|
func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
const secret = "internal-secret"
|
const secret = "internal-secret"
|
||||||
handler, err := StartHandler(Options{Dir: dir, InternalSecret: secret})
|
handler, err := StartHandler(dir, "", 0, secret, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ package artifactcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"encoding/json/jsontext"
|
"encoding/json"
|
||||||
"encoding/json/v2"
|
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -78,7 +77,6 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
h.touch(db, existing) // the client skips the upload, so this is the only sign the entry is still in use
|
|
||||||
h.twirpNotOK(w, r)
|
h.twirpNotOK(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,7 +97,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
"signed_upload_url": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +127,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
db.Close() // commitCache needs the store closed
|
db.Close() // commitCache needs the store closed
|
||||||
|
|
||||||
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
|
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
||||||
if err := h.commitCache(cache); err != nil {
|
if err := h.commitCache(cache); err != nil {
|
||||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||||
h.twirpNotOK(w, r)
|
h.twirpNotOK(w, r)
|
||||||
@@ -170,7 +168,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"signed_download_url": h.signedArtifactURL(cred, cache.ID, time.Now().Add(artifactURLTTL)),
|
"signed_download_url": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
"matched_key": cache.Key,
|
"matched_key": cache.Key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -248,8 +246,8 @@ type (
|
|||||||
v2FinalizeRequest struct {
|
v2FinalizeRequest struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
SizeBytes twirpInt64 `json:"size_bytes"`
|
SizeBytes json.Number `json:"size_bytes"`
|
||||||
SizeBytesCamel twirpInt64 `json:"sizeBytes"`
|
SizeBytesCamel json.Number `json:"sizeBytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
v2DownloadRequest struct {
|
v2DownloadRequest struct {
|
||||||
@@ -260,31 +258,6 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// twirpInt64 accepts its value as the JSON string the mapping prescribes or as a bare number.
|
|
||||||
type twirpInt64 int64
|
|
||||||
|
|
||||||
func (n *twirpInt64) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
|
||||||
val, err := dec.ReadValue()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
digits := []byte(val)
|
|
||||||
switch val.Kind() {
|
|
||||||
case 'n': // absent, keep the zero value
|
|
||||||
return nil
|
|
||||||
case '"':
|
|
||||||
if digits, err = jsontext.AppendUnquote(nil, val); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseInt(string(digits), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*n = twirpInt64(parsed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d v2DownloadRequest) keys() []string {
|
func (d v2DownloadRequest) keys() []string {
|
||||||
restoreKeys := d.RestoreKeys
|
restoreKeys := d.RestoreKeys
|
||||||
if len(restoreKeys) == 0 {
|
if len(restoreKeys) == 0 {
|
||||||
@@ -295,6 +268,6 @@ func (d v2DownloadRequest) keys() []string {
|
|||||||
|
|
||||||
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
||||||
var req T
|
var req T
|
||||||
err := json.UnmarshalRead(io.LimitReader(r.Body, 1<<20), &req)
|
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
|
||||||
return req, err
|
return req, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ package artifactcache
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json/v2"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -32,7 +32,7 @@ func v2Call(t *testing.T, handler *Handler, client *http.Client, method string,
|
|||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
got := map[string]any{}
|
got := map[string]any{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
return got
|
return got
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +66,16 @@ func getURL(t *testing.T, url string) []byte {
|
|||||||
return body
|
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, JobCredential{Repo: testRepo})
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
||||||
// with the upload URL it used.
|
// with the upload URL it used.
|
||||||
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
||||||
@@ -87,7 +97,7 @@ func saveV2(t *testing.T, handler *Handler, key, version string, content []byte)
|
|||||||
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
||||||
// or to replace a finalized entry.
|
// or to replace a finalized entry.
|
||||||
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
||||||
handler := newTestHandler(t, Policy{})
|
handler := startTestHandler(t)
|
||||||
content := []byte("the cached archive")
|
content := []byte("the cached archive")
|
||||||
|
|
||||||
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||||
@@ -116,7 +126,7 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
|
|||||||
// A large archive is staged as blocks and only put in order by the final block list, so
|
// 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.
|
// blocks that arrive out of order must still be assembled the way the client asked.
|
||||||
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||||
handler := newTestHandler(t, Policy{})
|
handler := startTestHandler(t)
|
||||||
|
|
||||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
uploadURL, _ := created["signed_upload_url"].(string)
|
uploadURL, _ := created["signed_upload_url"].(string)
|
||||||
@@ -153,7 +163,7 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheServiceV2Lookups(t *testing.T) {
|
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||||
handler := newTestHandler(t, Policy{})
|
handler := startTestHandler(t)
|
||||||
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||||
require.Equal(t, true, saved["ok"])
|
require.Equal(t, true, saved["ok"])
|
||||||
|
|
||||||
@@ -188,18 +198,6 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
assert.NotEmpty(t, reserved["signed_upload_url"])
|
assert.NotEmpty(t, reserved["signed_upload_url"])
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("a proxied job is handed the address its runner registered", func(t *testing.T) {
|
|
||||||
const proxy = "https://cache.example.invalid"
|
|
||||||
handler.RegisterJob("proxied", JobCredential{Repo: testRepo, PublicURL: proxy + "/"})
|
|
||||||
client := &http.Client{Transport: &bearerTransport{token: "proxied"}}
|
|
||||||
|
|
||||||
created := v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "proxied-key", "version": "v1"})
|
|
||||||
assert.True(t, strings.HasPrefix(created["signed_upload_url"].(string), proxy+blobPath+"/"))
|
|
||||||
|
|
||||||
got := v2Call(t, handler, client, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
|
||||||
assert.True(t, strings.HasPrefix(got["signed_download_url"].(string), proxy+apiPath+"/artifacts/"))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
||||||
@@ -227,7 +225,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
got := map[string]any{}
|
got := map[string]any{}
|
||||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, "deps-abc", got["cacheKey"])
|
assert.Equal(t, "deps-abc", got["cacheKey"])
|
||||||
assert.NotEmpty(t, got["archiveLocation"])
|
assert.NotEmpty(t, got["archiveLocation"])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ type Request struct {
|
|||||||
Size int64 `json:"cacheSize"`
|
Size int64 `json:"cacheSize"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Request) ToCache() *Cache {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ret := &Cache{
|
||||||
|
Key: c.Key,
|
||||||
|
Version: c.Version,
|
||||||
|
Size: c.Size,
|
||||||
|
}
|
||||||
|
if c.Size == 0 {
|
||||||
|
// So the request comes from old versions of actions, like `actions/cache@v2`.
|
||||||
|
// It doesn't send cache size. Set it to -1 to indicate that.
|
||||||
|
ret.Size = -1
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
ID uint64 `json:"id" boltholdKey:"ID"`
|
ID uint64 `json:"id" boltholdKey:"ID"`
|
||||||
Repo string `json:"repo" boltholdIndex:"Repo"`
|
Repo string `json:"repo" boltholdIndex:"Repo"`
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestFrontResultsService(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer gitea.Close()
|
defer gitea.Close()
|
||||||
|
|
||||||
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
handler, err := StartHandler(t.TempDir(), "127.0.0.1", 0, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
const token = "forward-token"
|
const token = "forward-token"
|
||||||
|
|||||||
@@ -143,13 +143,9 @@ func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
|||||||
http.ServeFile(w, r, name)
|
http.ServeFile(w, r, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove deletes an entry's blob and any staged parts. It reports failure so the caller can
|
func (s *Storage) Remove(id uint64) {
|
||||||
// keep the entry and retry, rather than dropping the only reference to bytes on disk.
|
_ = os.Remove(s.filename(id))
|
||||||
func (s *Storage) Remove(id uint64) error {
|
_ = os.RemoveAll(s.tempDir(id))
|
||||||
if err := os.Remove(s.filename(id)); err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.RemoveAll(s.tempDir(id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) filename(id uint64) string {
|
func (s *Storage) filename(id uint64) string {
|
||||||
|
|||||||
+108
-36
@@ -6,7 +6,7 @@ package artifacts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json/v2"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -50,29 +50,65 @@ type ResponseMessage struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WritableFile interface {
|
||||||
|
io.WriteCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriteFS interface {
|
||||||
|
OpenWritable(name string) (WritableFile, error)
|
||||||
|
OpenAppendable(name string) (WritableFile, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type readWriteFSImpl struct{}
|
||||||
|
|
||||||
|
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
|
||||||
|
return os.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fwfs readWriteFSImpl) OpenWritable(name string) (WritableFile, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = file.Seek(0, io.SeekEnd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
var gzipExtension = ".gz__"
|
var gzipExtension = ".gz__"
|
||||||
|
|
||||||
func safeResolve(baseDir, relPath string) string {
|
func safeResolve(baseDir, relPath string) string {
|
||||||
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
|
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, value any) {
|
func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
||||||
data, err := json.Marshal(value)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if _, err := w.Write(data); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploads(router *httprouter.Router, baseDir string) {
|
|
||||||
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
runID := params.ByName("runId")
|
runID := params.ByName("runId")
|
||||||
|
|
||||||
writeJSON(w, FileContainerResourceURL{
|
json, err := json.Marshal(FileContainerResourceURL{
|
||||||
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
|
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
@@ -86,47 +122,67 @@ func uploads(router *httprouter.Router, baseDir string) {
|
|||||||
safeRunPath := safeResolve(baseDir, runID)
|
safeRunPath := safeResolve(baseDir, runID)
|
||||||
safePath := safeResolve(safeRunPath, itemPath)
|
safePath := safeResolve(safeRunPath, itemPath)
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
|
file, err := func() (WritableFile, error) {
|
||||||
panic(err)
|
contentRange := req.Header.Get("Content-Range")
|
||||||
|
if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
|
||||||
|
return fsys.OpenAppendable(safePath)
|
||||||
}
|
}
|
||||||
flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
|
return fsys.OpenWritable(safePath)
|
||||||
appendUpload := req.Header.Get("Content-Range")
|
}()
|
||||||
if appendUpload != "" && !strings.HasPrefix(appendUpload, "bytes 0-") {
|
|
||||||
flags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
|
|
||||||
}
|
|
||||||
file, err := os.OpenFile(safePath, flags, 0o644)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
if req.Body == nil {
|
|
||||||
panic(errors.New("no body given"))
|
writer, ok := file.(io.Writer)
|
||||||
|
if !ok {
|
||||||
|
panic(errors.New("File is not writable"))
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(file, req.Body)
|
if req.Body == nil {
|
||||||
|
panic(errors.New("No body given"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, req.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, ResponseMessage{
|
json, err := json.Marshal(ResponseMessage{
|
||||||
Message: "success",
|
Message: "success",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
writeJSON(w, ResponseMessage{
|
json, err := json.Marshal(ResponseMessage{
|
||||||
Message: "success",
|
Message: "success",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloads(router *httprouter.Router, baseDir string) {
|
func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
||||||
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
runID := params.ByName("runId")
|
runID := params.ByName("runId")
|
||||||
|
|
||||||
safePath := safeResolve(baseDir, runID)
|
safePath := safeResolve(baseDir, runID)
|
||||||
|
|
||||||
entries, err := os.ReadDir(safePath)
|
entries, err := fs.ReadDir(fsys, safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -139,10 +195,18 @@ func downloads(router *httprouter.Router, baseDir string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, NamedFileContainerResourceURLResponse{
|
json, err := json.Marshal(NamedFileContainerResourceURLResponse{
|
||||||
Count: len(list),
|
Count: len(list),
|
||||||
Value: list,
|
Value: list,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
@@ -151,7 +215,7 @@ func downloads(router *httprouter.Router, baseDir string) {
|
|||||||
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
||||||
|
|
||||||
var files []ContainerItem
|
var files []ContainerItem
|
||||||
err := filepath.WalkDir(safePath, func(path string, entry fs.DirEntry, err error) error {
|
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
rel, err := filepath.Rel(safePath, path)
|
rel, err := filepath.Rel(safePath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -177,9 +241,17 @@ func downloads(router *httprouter.Router, baseDir string) {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, ContainerItemResponse{
|
json, err := json.Marshal(ContainerItemResponse{
|
||||||
Value: files,
|
Value: files,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(json)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
@@ -187,16 +259,15 @@ func downloads(router *httprouter.Router, baseDir string) {
|
|||||||
|
|
||||||
safePath := safeResolve(baseDir, path)
|
safePath := safeResolve(baseDir, path)
|
||||||
|
|
||||||
file, err := os.Open(safePath)
|
file, err := fsys.Open(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// try gzip file
|
// try gzip file
|
||||||
file, err = os.Open(safePath + gzipExtension)
|
file, err = fsys.Open(safePath + gzipExtension)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
w.Header().Add("Content-Encoding", "gzip")
|
w.Header().Add("Content-Encoding", "gzip")
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(w, file)
|
_, err = io.Copy(w, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,8 +287,9 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
|||||||
router := httprouter.New()
|
router := httprouter.New()
|
||||||
|
|
||||||
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
||||||
uploads(router, artifactPath)
|
fsys := readWriteFSImpl{}
|
||||||
downloads(router, artifactPath)
|
uploads(router, artifactPath, fsys)
|
||||||
|
downloads(router, artifactPath, fsys)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%s", addr, port),
|
Addr: fmt.Sprintf("%s:%s", addr, port),
|
||||||
|
|||||||
+314
-22
@@ -7,7 +7,8 @@ package artifacts
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"encoding/json/v2"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -17,18 +18,238 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type writableMapFile struct {
|
||||||
|
fstest.MapFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *writableMapFile) Write(data []byte) (int, error) {
|
||||||
|
f.Data = data
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *writableMapFile) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeMapFS struct {
|
||||||
|
fstest.MapFS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fsys writeMapFS) OpenWritable(name string) (WritableFile, error) {
|
||||||
|
file := &writableMapFile{
|
||||||
|
MapFile: fstest.MapFile{
|
||||||
|
Data: []byte("content2"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fsys.MapFS[name] = &file.MapFile
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fsys writeMapFS) OpenAppendable(name string) (WritableFile, error) {
|
||||||
|
file := &writableMapFile{
|
||||||
|
MapFile: fstest.MapFile{
|
||||||
|
Data: []byte("content2"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fsys.MapFS[name] = &file.MapFile
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewArtifactUploadPrepare(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := FileContainerResourceURL{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("http://localhost/upload/1", response.FileContainerResourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactUploadBlob(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=some/file", strings.NewReader("content"))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ResponseMessage{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("success", response.Message)
|
||||||
|
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalizeArtifactUpload(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPatch, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ResponseMessage{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("success", response.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListArtifacts(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/1/file.txt": {
|
||||||
|
Data: []byte(""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
response := NamedFileContainerResourceURLResponse{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(1, response.Count)
|
||||||
|
assert.Equal("file.txt", response.Value[0].Name)
|
||||||
|
assert.Equal("http://localhost/download/1", response.Value[0].FileContainerResourceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListArtifactContainer(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/1/some/file": {
|
||||||
|
Data: []byte(""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/download/1?itemPath=some/file", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ContainerItemResponse{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(response.Value, 1)
|
||||||
|
assert.Equal("some/file", response.Value[0].Path)
|
||||||
|
assert.Equal("file", response.Value[0].ItemType)
|
||||||
|
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadArtifactFile(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/1/some/file": {
|
||||||
|
Data: []byte("content"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/1/some/file", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rr.Body.Bytes()
|
||||||
|
|
||||||
|
assert.Equal("content", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
|
||||||
|
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
|
||||||
|
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
|
||||||
|
// and reachable everywhere, including when the CI job is itself a container.
|
||||||
func TestArtifactFlow(t *testing.T) {
|
func TestArtifactFlow(t *testing.T) {
|
||||||
artifactPath := t.TempDir()
|
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()
|
router := httprouter.New()
|
||||||
uploads(router, artifactPath)
|
fsys := readWriteFSImpl{}
|
||||||
downloads(router, artifactPath)
|
uploads(router, artifactPath, fsys)
|
||||||
|
downloads(router, artifactPath, fsys)
|
||||||
server := httptest.NewServer(router)
|
server := httptest.NewServer(router)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -36,6 +257,8 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
client := server.Client()
|
client := server.Client()
|
||||||
client.Timeout = 5 * time.Second
|
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) {
|
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req, err := http.NewRequest(method, rawURL, body)
|
req, err := http.NewRequest(method, rawURL, body)
|
||||||
@@ -66,8 +289,6 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
|
|
||||||
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
require.NoError(t, json.Unmarshal(data, &msg))
|
|
||||||
require.Equal(t, "success", msg.Message)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
@@ -93,21 +314,6 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
require.Equal(t, content, string(stored))
|
require.Equal(t, content, string(stored))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("content-range", func(t *testing.T) {
|
|
||||||
const rawURL = "/upload/4?itemPath=chunks.txt"
|
|
||||||
status, data := request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("first"),
|
|
||||||
http.Header{"Content-Range": []string{"bytes 0-4/11"}})
|
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("-second"),
|
|
||||||
http.Header{"Content-Range": []string{"bytes 5-11/11"}})
|
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
|
||||||
|
|
||||||
stored, err := os.ReadFile(filepath.Join(artifactPath, "4", "chunks.txt"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "first-second", string(stored))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("gzip-roundtrip", func(t *testing.T) {
|
t.Run("gzip-roundtrip", func(t *testing.T) {
|
||||||
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
||||||
|
|
||||||
@@ -159,7 +365,9 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSafeResolve(t *testing.T) {
|
func TestMkdirFsImplSafeResolve(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
baseDir := "/foo/bar"
|
baseDir := "/foo/bar"
|
||||||
|
|
||||||
tests := map[string]struct {
|
tests := map[string]struct {
|
||||||
@@ -177,13 +385,97 @@ func TestSafeResolve(t *testing.T) {
|
|||||||
|
|
||||||
for name, tc := range tests {
|
for name, tc := range tests {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
require.Equal(t, tc.want, safeResolve(baseDir, tc.input))
|
assert.Equal(tc.want, safeResolve(baseDir, tc.input))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
|
||||||
|
fsys := readWriteFSImpl{}
|
||||||
|
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
|
||||||
|
|
||||||
|
w, err := fsys.OpenWritable(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("first"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
w, err = fsys.OpenAppendable(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("-second"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
got, err := os.ReadFile(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "first-second", string(got))
|
||||||
|
|
||||||
|
w, err = fsys.OpenWritable(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("replaced"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
got, err = os.ReadFile(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "replaced", string(got))
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
|
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
|
||||||
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
|
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
|
||||||
require.NotNil(t, cancel)
|
require.NotNil(t, cancel)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
||||||
|
"artifact/server/path/some/file": {
|
||||||
|
Data: []byte("content"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
downloads(router, "artifact/server/path", memfs)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/2/../../some/file", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
||||||
|
}
|
||||||
|
|
||||||
|
data := rr.Body.Bytes()
|
||||||
|
|
||||||
|
assert.Equal("content", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactUploadBlobUnsafePath(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
||||||
|
|
||||||
|
router := httprouter.New()
|
||||||
|
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=../../some/file", strings.NewReader("content"))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
assert.Fail("Wrong status")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ResponseMessage{}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal("success", response.Message)
|
||||||
|
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import "slices"
|
||||||
|
|
||||||
|
// CartesianProduct takes map of lists and returns list of unique tuples
|
||||||
|
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
||||||
|
listNames := make([]string, 0)
|
||||||
|
lists := make([][]any, 0)
|
||||||
|
for k, v := range mapOfLists {
|
||||||
|
listNames = append(listNames, k)
|
||||||
|
lists = append(lists, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
listCart := cartN(lists...)
|
||||||
|
|
||||||
|
rtn := make([]map[string]any, 0)
|
||||||
|
for _, list := range listCart {
|
||||||
|
vMap := make(map[string]any)
|
||||||
|
for i, v := range list {
|
||||||
|
vMap[listNames[i]] = v
|
||||||
|
}
|
||||||
|
rtn = append(rtn, vMap)
|
||||||
|
}
|
||||||
|
return rtn
|
||||||
|
}
|
||||||
|
|
||||||
|
func cartN(a ...[]any) [][]any {
|
||||||
|
c := 1
|
||||||
|
for _, a := range a {
|
||||||
|
c *= len(a)
|
||||||
|
}
|
||||||
|
if c == 0 || len(a) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p := make([][]any, c)
|
||||||
|
b := make([]any, c*len(a))
|
||||||
|
n := make([]int, len(a))
|
||||||
|
s := 0
|
||||||
|
for i := range p {
|
||||||
|
e := s + len(a)
|
||||||
|
pi := b[s:e]
|
||||||
|
p[i] = pi
|
||||||
|
s = e
|
||||||
|
for j, n := range n {
|
||||||
|
pi[j] = a[j][n]
|
||||||
|
}
|
||||||
|
for j := range slices.Backward(n) {
|
||||||
|
n[j]++
|
||||||
|
if n[j] < len(a[j]) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n[j] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
+24
-1
@@ -54,6 +54,22 @@ func NewPipelineExecutor(executors ...Executor) Executor {
|
|||||||
return rtn
|
return rtn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewConditionalExecutor creates a new executor based on conditions
|
||||||
|
func NewConditionalExecutor(conditional Conditional, trueExecutor, falseExecutor Executor) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if conditional(ctx) {
|
||||||
|
if trueExecutor != nil {
|
||||||
|
return trueExecutor(ctx)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if falseExecutor != nil {
|
||||||
|
return falseExecutor(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewErrorExecutor creates a new executor that always errors out
|
// NewErrorExecutor creates a new executor that always errors out
|
||||||
func NewErrorExecutor(err error) Executor {
|
func NewErrorExecutor(err error) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -171,8 +187,15 @@ func (e Executor) Finally(finally Executor) Executor {
|
|||||||
err := e(ctx)
|
err := e(ctx)
|
||||||
err2 := finally(ctx)
|
err2 := finally(ctx)
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
return fmt.Errorf("error occurred running finally: %v (original error: %v)", err2, err)
|
return fmt.Errorf("Error occurred running finally: %v (original error: %v)", err2, err)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not return an inverted conditional
|
||||||
|
func (c Conditional) Not() Conditional {
|
||||||
|
return func(ctx context.Context) bool {
|
||||||
|
return !c(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,43 @@ func TestNewWorkflow(t *testing.T) {
|
|||||||
assert.Equal(2, runcount)
|
assert.Equal(2, runcount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewConditionalExecutor(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
trueCount := 0
|
||||||
|
falseCount := 0
|
||||||
|
|
||||||
|
err := NewConditionalExecutor(func(ctx context.Context) bool {
|
||||||
|
return false
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
trueCount++
|
||||||
|
return nil
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
falseCount++
|
||||||
|
return nil
|
||||||
|
})(ctx)
|
||||||
|
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(0, trueCount)
|
||||||
|
assert.Equal(1, falseCount)
|
||||||
|
|
||||||
|
err = NewConditionalExecutor(func(ctx context.Context) bool {
|
||||||
|
return true
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
trueCount++
|
||||||
|
return nil
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
falseCount++
|
||||||
|
return nil
|
||||||
|
})(ctx)
|
||||||
|
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(1, trueCount)
|
||||||
|
assert.Equal(1, falseCount)
|
||||||
|
}
|
||||||
|
|
||||||
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
|
// 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
|
// 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.
|
// find the gate already open so the last one still finishes with no partner left.
|
||||||
@@ -186,3 +223,10 @@ func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
|||||||
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+21
-31
@@ -36,6 +36,7 @@ var (
|
|||||||
cloneLocks lock.Keyed[string] // key: clone target directory
|
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||||
|
|
||||||
ErrShortRef = errors.New("short SHA references are not supported")
|
ErrShortRef = errors.New("short SHA references are not supported")
|
||||||
|
ErrNoRepo = errors.New("unable to find git repo")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
||||||
@@ -186,16 +187,19 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindGithubRepo get the repo
|
// FindGithubRepo get the repo
|
||||||
func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, error) {
|
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
||||||
goGitMu.Lock()
|
goGitMu.Lock()
|
||||||
defer goGitMu.Unlock()
|
defer goGitMu.Unlock()
|
||||||
|
if remoteName == "" {
|
||||||
|
remoteName = "origin"
|
||||||
|
}
|
||||||
|
|
||||||
url, err := findGitRemoteURL(ctx, file, "origin")
|
url, err := findGitRemoteURL(ctx, file, remoteName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_, slug := findGitSlug(url, githubInstance)
|
_, slug, err := findGitSlug(url, githubInstance)
|
||||||
return slug, nil
|
return slug, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
||||||
@@ -222,25 +226,25 @@ func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error
|
|||||||
return remote.Config().URLs[0], nil
|
return remote.Config().URLs[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGitSlug(url, githubInstance string) (string, string) {
|
func findGitSlug(url, githubInstance string) (string, string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "CodeCommit", matches[2]
|
return "CodeCommit", matches[2], nil
|
||||||
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "CodeCommit", matches[2]
|
return "CodeCommit", matches[2], nil
|
||||||
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
} else if githubInstance != "github.com" {
|
} else if githubInstance != "github.com" {
|
||||||
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
||||||
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
|
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
|
||||||
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
|
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", url
|
return "", url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
||||||
@@ -274,12 +278,11 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
|||||||
return r, true, nil
|
return r, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
if err != nil {
|
||||||
case err != nil:
|
|
||||||
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||||
case len(remote.Config().URLs) == 0:
|
} else if len(remote.Config().URLs) == 0 {
|
||||||
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
||||||
default:
|
} else {
|
||||||
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(input.Dir); err != nil {
|
if err := os.RemoveAll(input.Dir); err != nil {
|
||||||
@@ -342,16 +345,6 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
|
|||||||
return fetchOptions, pullOptions
|
return fetchOptions, pullOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
// staleRefreshErr reports why a failed refresh must abort: the resolve and
|
|
||||||
// checkout that follow are local and succeed on a cancelled context, which
|
|
||||||
// would hand back the cached revision as if it were fresh.
|
|
||||||
func staleRefreshErr(ctx context.Context, err error) error {
|
|
||||||
if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGitCloneExecutor creates an executor to clone git repos
|
// NewGitCloneExecutor creates an executor to clone git repos
|
||||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -392,7 +385,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !isOfflineMode {
|
if !isOfflineMode {
|
||||||
err = r.FetchContext(ctx, &fetchOptions)
|
err = r.Fetch(&fetchOptions)
|
||||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -461,12 +454,9 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
switch {
|
switch {
|
||||||
case !isOfflineMode && !shallow:
|
case !isOfflineMode && !shallow:
|
||||||
// In shallow mode the depth-limited fetch above already advanced the ref.
|
// In shallow mode the depth-limited fetch above already advanced the ref.
|
||||||
if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||||
}
|
}
|
||||||
if err := staleRefreshErr(ctx, err); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case isOfflineMode && reused:
|
case isOfflineMode && reused:
|
||||||
reusedMsg = " (reused in offline mode)"
|
reusedMsg = " (reused in offline mode)"
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-67
@@ -6,24 +6,18 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
gogit "github.com/go-git/go-git/v5"
|
|
||||||
gogitconfig "github.com/go-git/go-git/v5/config"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -51,7 +45,9 @@ func TestFindGitSlug(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range slugTests {
|
for _, tt := range slugTests {
|
||||||
provider, slug := findGitSlug(tt.url, "github.com")
|
provider, slug, err := findGitSlug(tt.url, "github.com")
|
||||||
|
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(tt.provider, provider)
|
assert.Equal(tt.provider, provider)
|
||||||
assert.Equal(tt.slug, slug)
|
assert.Equal(tt.slug, slug)
|
||||||
}
|
}
|
||||||
@@ -85,20 +81,45 @@ func cleanGitHooks(dir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindGithubRepoUsesOrigin(t *testing.T) {
|
func TestFindGitRemoteURL(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
basedir := t.TempDir()
|
||||||
|
err := gitCmd("init", basedir)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
err = cleanGitHooks(basedir)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
remoteURL := "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name"
|
||||||
|
err = gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
u, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(remoteURL, u)
|
||||||
|
|
||||||
|
remoteURL = "git@github.com/AwesomeOwner/MyAwesomeRepo.git"
|
||||||
|
err = gitCmd("-C", basedir, "remote", "add", "upstream", remoteURL)
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
u, err = findGitRemoteURL(context.Background(), basedir, "upstream")
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(remoteURL, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindGithubRepoUsesOriginAndCustomRemote(t *testing.T) {
|
||||||
basedir := t.TempDir()
|
basedir := t.TempDir()
|
||||||
const remoteURL = "https://github.com/owner/repo.git"
|
|
||||||
require.NoError(t, gitCmd("init", basedir))
|
require.NoError(t, gitCmd("init", basedir))
|
||||||
require.NoError(t, cleanGitHooks(basedir))
|
require.NoError(t, cleanGitHooks(basedir))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
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"))
|
||||||
|
|
||||||
url, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, remoteURL, url)
|
|
||||||
|
|
||||||
slug, err := FindGithubRepo(context.Background(), basedir, "github.com")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "owner/repo", slug)
|
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) {
|
func TestGitFindRef(t *testing.T) {
|
||||||
@@ -589,55 +610,3 @@ func TestAcquireCloneLock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// An unresponsive remote must not pin a job: the refresh has to be interruptible.
|
|
||||||
func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
|
||||||
block := make(chan struct{})
|
|
||||||
reached := make(chan struct{})
|
|
||||||
var once sync.Once
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
|
||||||
once.Do(func() { close(reached) })
|
|
||||||
<-block
|
|
||||||
}))
|
|
||||||
t.Cleanup(func() {
|
|
||||||
close(block)
|
|
||||||
server.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "cached-action")
|
|
||||||
repo, err := gogit.PlainInit(dir, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = repo.CreateRemote(&gogitconfig.RemoteConfig{Name: "origin", URLs: []string{server.URL}})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir})(ctx)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-reached:
|
|
||||||
case <-time.After(10 * time.Second):
|
|
||||||
t.Fatal("the executor never reached the remote")
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.Error(t, err)
|
|
||||||
case <-time.After(10 * time.Second):
|
|
||||||
t.Fatal("fetch ignored context cancellation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStaleRefreshErr(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
require.NoError(t, staleRefreshErr(ctx, errors.New("remote hung up")))
|
|
||||||
|
|
||||||
cancel()
|
|
||||||
require.ErrorIs(t, staleRefreshErr(ctx, errors.New("remote hung up")), context.Canceled)
|
|
||||||
require.NoError(t, staleRefreshErr(ctx, gogit.NoErrAlreadyUpToDate))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
|||||||
line, err := pBuf.ReadString('\n')
|
line, err := pBuf.ReadString('\n')
|
||||||
w, _ := lw.buffer.WriteString(line)
|
w, _ := lw.buffer.WriteString(line)
|
||||||
written += w
|
written += w
|
||||||
if err != nil {
|
if err == nil {
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return written, err
|
|
||||||
}
|
|
||||||
lw.handleLine(lw.buffer.String())
|
lw.handleLine(lw.buffer.String())
|
||||||
lw.buffer.Reset()
|
lw.buffer.Reset()
|
||||||
|
} else if err == io.EOF {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return written, nil
|
return written, nil
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ type NewContainerInput struct {
|
|||||||
Privileged bool
|
Privileged bool
|
||||||
UsernsMode string
|
UsernsMode string
|
||||||
Platform string
|
Platform string
|
||||||
RunnerOptions string // container options the runner was configured with, trusted
|
Options string
|
||||||
WorkflowOptions string // container options the workflow asked for, untrusted
|
|
||||||
NetworkAliases []string
|
NetworkAliases []string
|
||||||
ExposedPorts nat.PortSet
|
ExposedPorts nat.PortSet
|
||||||
PortBindings nat.PortMap
|
PortBindings nat.PortMap
|
||||||
@@ -89,7 +88,9 @@ type Info struct {
|
|||||||
// Container for managing docker run containers
|
// Container for managing docker run containers
|
||||||
type Container interface {
|
type Container interface {
|
||||||
Create(capAdd, capDrop []string) common.Executor
|
Create(capAdd, capDrop []string) common.Executor
|
||||||
|
ConnectToNetwork(name string) common.Executor
|
||||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||||
|
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
||||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||||
Inspect(ctx context.Context) (*Info, error)
|
Inspect(ctx context.Context) (*Info, error)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json/jsontext"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -350,7 +351,7 @@ type containerConfig struct {
|
|||||||
// parse parses the args for the specified command and generates a Config,
|
// parse parses the args for the specified command and generates a Config,
|
||||||
// a HostConfig and returns them with the specified command.
|
// a HostConfig and returns them with the specified command.
|
||||||
// If the specified args are not valid, it will return an error.
|
// If the specified args are not valid, it will return an error.
|
||||||
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { //nolint:gocyclo,unparam // verbatim copy from docker/cli
|
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { //nolint:gocyclo // verbatim copy from docker/cli
|
||||||
var (
|
var (
|
||||||
attachStdin = copts.attach.Get("stdin")
|
attachStdin = copts.attach.Get("stdin")
|
||||||
attachStdout = copts.attach.Get("stdout")
|
attachStdout = copts.attach.Get("stdout")
|
||||||
@@ -958,11 +959,11 @@ func parseSecurityOpts(securityOpts []string) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return securityOpts, fmt.Errorf("opening seccomp profile (%s) failed: %w", v, err)
|
return securityOpts, fmt.Errorf("opening seccomp profile (%s) failed: %w", v, err)
|
||||||
}
|
}
|
||||||
profile := jsontext.Value(f)
|
var b bytes.Buffer
|
||||||
if err := profile.Compact(); err != nil {
|
if err := json.Compact(&b, f); err != nil {
|
||||||
return securityOpts, fmt.Errorf("compacting json for seccomp profile (%s) failed: %w", v, err)
|
return securityOpts, fmt.Errorf("compacting json for seccomp profile (%s) failed: %w", v, err)
|
||||||
}
|
}
|
||||||
securityOpts[key] = "seccomp=" + string(profile)
|
securityOpts[key] = "seccomp=" + b.String()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/docker/cli/opts"
|
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
@@ -53,16 +51,15 @@ func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *
|
|||||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||||
flags.SetOutput(io.Discard)
|
flags.SetOutput(io.Discard)
|
||||||
copts := addFlags(flags)
|
copts := addFlags(flags)
|
||||||
copts.env = opts.NewListOpts(validateEnv) // addFlags registered this field's address, so the swap takes effect
|
|
||||||
cf := registerCreateFlags(flags)
|
cf := registerCreateFlags(flags)
|
||||||
|
|
||||||
args, err := shellquote.Split(options)
|
args, err := shellquote.Split(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return flags, copts, cf, fmt.Errorf("cannot split container options: '%s': '%w'", options, err)
|
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
return flags, copts, cf, fmt.Errorf("cannot parse container options: '%s': '%w'", options, err)
|
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return flags, copts, cf, nil
|
return flags, copts, cf, nil
|
||||||
@@ -76,30 +73,6 @@ func createFlagsFromOptions(options string) *createFlags {
|
|||||||
return cf
|
return cf
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateEnv is opts.ValidateEnv without its lookup of a bare name in the runner's environment.
|
|
||||||
func validateEnv(val string) (string, error) {
|
|
||||||
if name, _, _ := strings.Cut(val, "="); name == "" {
|
|
||||||
return "", errors.New("invalid environment variable: " + val)
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rejectHostReadingOptions refuses the flags naming files that are read here, on the
|
|
||||||
// runner, rather than in the container.
|
|
||||||
func rejectHostReadingOptions(options string) error {
|
|
||||||
flags, _, _, err := parseContainerOptions(options)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range []string{"env-file", "label-file"} {
|
|
||||||
if flags.Changed(name) {
|
|
||||||
return fmt.Errorf("container option --%s reads files from the runner and is not allowed in a workflow", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cf *createFlags) validate() error {
|
func (cf *createFlags) validate() error {
|
||||||
if !slices.Contains(pullPolicies, cf.pull) {
|
if !slices.Contains(pullPolicies, cf.pull) {
|
||||||
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
|
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ func TestCreateFlagsValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
||||||
input := &NewContainerInput{Platform: "linux/amd64", RunnerOptions: "--pull never", WorkflowOptions: "--platform linux/arm64"}
|
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
||||||
cr, ok := NewContainer(input).(*containerReference)
|
cr, ok := NewContainer(input).(*containerReference)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, "linux/arm64", input.Platform)
|
assert.Equal(t, "linux/arm64", input.Platform)
|
||||||
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||||
|
|
||||||
kept := &NewContainerInput{Platform: "linux/amd64", RunnerOptions: "--privileged"}
|
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
|
||||||
NewContainer(kept)
|
NewContainer(kept)
|
||||||
assert.Equal(t, "linux/amd64", kept.Platform)
|
assert.Equal(t, "linux/amd64", kept.Platform)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json/v2"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ type dockerMessage struct {
|
|||||||
Stream string `json:"stream"`
|
Stream string `json:"stream"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
ErrorDetail struct {
|
ErrorDetail struct {
|
||||||
Message string `json:"message"`
|
Message string
|
||||||
} `json:"errorDetail"`
|
}
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Progress string `json:"progress"`
|
Progress string `json:"progress"`
|
||||||
}
|
}
|
||||||
@@ -60,16 +60,15 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
|||||||
return errors.New(msg.ErrorDetail.Message)
|
return errors.New(msg.ErrorDetail.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
if msg.Status != "" {
|
||||||
case msg.Status != "":
|
|
||||||
if msg.Progress != "" {
|
if msg.Progress != "" {
|
||||||
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
||||||
} else {
|
} else {
|
||||||
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
|
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
|
||||||
}
|
}
|
||||||
case msg.Stream != "":
|
} else if msg.Stream != "" {
|
||||||
writeLog(logger, isError, "%s", msg.Stream)
|
writeLog(logger, isError, "%s", msg.Stream)
|
||||||
default:
|
} else {
|
||||||
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ func TestRemoveOrphanNetworks(t *testing.T) {
|
|||||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||||
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
||||||
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||||
{ID: "orphan"},
|
{Network: network.Network{ID: "orphan"}},
|
||||||
{ID: "busy"},
|
{Network: network.Network{ID: "busy"}},
|
||||||
{ID: "starting"},
|
{Network: network.Network{ID: "starting"}},
|
||||||
}}, nil)
|
}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||||
|
|||||||
+188
-95
@@ -14,8 +14,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -58,7 +58,7 @@ func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
|||||||
cr := new(containerReference)
|
cr := new(containerReference)
|
||||||
cr.input = input
|
cr.input = input
|
||||||
// Resolved up front because the image pull runs before the container is created.
|
// Resolved up front because the image pull runs before the container is created.
|
||||||
cf := createFlagsFromOptions(input.allOptions())
|
cf := createFlagsFromOptions(input.Options)
|
||||||
if cf.platform != "" {
|
if cf.platform != "" {
|
||||||
cr.input.Platform = cf.platform
|
cr.input.Platform = cf.platform
|
||||||
}
|
}
|
||||||
@@ -66,14 +66,37 @@ func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
|||||||
return cr
|
return cr
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportsContainerImagePlatform reports whether the Docker server API version
|
func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
|
||||||
// is 1.41 and beyond
|
return common.
|
||||||
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) (bool, error) {
|
NewDebugExecutor("docker network connect %s %s", name, cr.input.Name).
|
||||||
|
Then(
|
||||||
|
common.NewPipelineExecutor(
|
||||||
|
cr.connect(),
|
||||||
|
cr.connectToNetwork(name, cr.input.NetworkAliases),
|
||||||
|
).IfNot(common.Dryrun),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cr *containerReference) connectToNetwork(name string, aliases []string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
_, err := cr.cli.NetworkConnect(ctx, name, client.NetworkConnectOptions{
|
||||||
|
Container: cr.input.Name,
|
||||||
|
EndpointConfig: &network.EndpointSettings{
|
||||||
|
Aliases: aliases,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportsContainerImagePlatform returns true if the underlying Docker server
|
||||||
|
// API version is 1.41 and beyond
|
||||||
|
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
|
||||||
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
|
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("get docker API version: %w", err)
|
common.Logger(ctx).Panicf("Failed to get Docker API Version: %s", err)
|
||||||
}
|
}
|
||||||
return versions.GreaterThanOrEqualTo(ver.APIVersion, "1.41"), nil
|
return versions.GreaterThanOrEqualTo(ver.APIVersion, "1.41")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
||||||
@@ -525,39 +548,29 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// allOptions puts the runner's options first, so a flag both sources set ends up the workflow's.
|
|
||||||
func (input *NewContainerInput) allOptions() string {
|
|
||||||
return strings.TrimSpace(input.RunnerOptions + " " + input.WorkflowOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig, error) {
|
func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig, error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
options := cr.input.allOptions()
|
input := cr.input
|
||||||
|
|
||||||
if options == "" {
|
if input.Options == "" {
|
||||||
return config, hostConfig, nil
|
return config, hostConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea, checked here because the parse below is what would read those files
|
|
||||||
if err := rejectHostReadingOptions(cr.input.WorkflowOptions); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse configuration from CLI container.options
|
// parse configuration from CLI container.options
|
||||||
flags, copts, cf, err := parseContainerOptions(options)
|
flags, copts, cf, err := parseContainerOptions(input.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cf.validate(); err != nil {
|
if err := cf.validate(); err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
|
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
|
||||||
// In the old fork version, the code is
|
// In the old fork version, the code is
|
||||||
// if len(copts.netMode.Value()) == 0 {
|
// if len(copts.netMode.Value()) == 0 {
|
||||||
// if err = copts.netMode.Set("host"); err != nil {
|
// if err = copts.netMode.Set("host"); err != nil {
|
||||||
// return nil, nil, fmt.Errorf("cannot parse networkmode=host. This is an internal error and should not happen: '%w'", err)
|
// return nil, nil, fmt.Errorf("Cannot parse networkmode=host. This is an internal error and should not happen: '%w'", err)
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// And it has been commented with:
|
// And it has been commented with:
|
||||||
@@ -569,7 +582,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
|
|
||||||
if len(copts.netMode.Value()) == 0 {
|
if len(copts.netMode.Value()) == 0 {
|
||||||
if err = copts.netMode.Set(cr.input.NetworkMode); err != nil {
|
if err = copts.netMode.Set(cr.input.NetworkMode); err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err)
|
return nil, nil, fmt.Errorf("Cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,23 +594,24 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
|
|
||||||
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea, forcing --privileged off is not enough, other options reach the host too
|
// For Gitea
|
||||||
|
// When privileged mode is disabled, container.options is workflow-controlled
|
||||||
|
// untrusted input. Strip the HostConfig fields that would let a workflow break
|
||||||
|
// out of the container (host namespaces, capability expansion, security profile
|
||||||
|
// overrides, device and runtime access). Otherwise these survive into the final
|
||||||
|
// HostConfig even though --privileged is forced off.
|
||||||
if !hostConfig.Privileged {
|
if !hostConfig.Privileged {
|
||||||
trusted, err := parseOptionsHostConfig(cr.input.RunnerOptions)
|
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig)
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig, trusted)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
||||||
|
|
||||||
err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
|
err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot merge container.Config options: '%s': '%w'", options, err)
|
return nil, nil, fmt.Errorf("Cannot merge container.Config options: '%s': '%w'", input.Options, err)
|
||||||
}
|
}
|
||||||
logger.Debugf("Merged container.Config ==> %+v", config)
|
logger.Debugf("Merged container.Config ==> %+v", config)
|
||||||
|
|
||||||
@@ -609,15 +623,14 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
networkMode := hostConfig.NetworkMode
|
networkMode := hostConfig.NetworkMode
|
||||||
err = mergo.Merge(hostConfig, containerConfig.HostConfig, mergo.WithOverride)
|
err = mergo.Merge(hostConfig, containerConfig.HostConfig, mergo.WithOverride)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot merge container.HostConfig options: '%s': '%w'", options, err)
|
return nil, nil, fmt.Errorf("Cannot merge container.HostConfig options: '%s': '%w'", input.Options, err)
|
||||||
}
|
}
|
||||||
hostConfig.Binds = binds
|
hostConfig.Binds = binds
|
||||||
hostConfig.Mounts = mounts
|
hostConfig.Mounts = mounts
|
||||||
if cf.name != "" {
|
if cf.name != "" {
|
||||||
logger.Warn("--name in the options will be ignored.")
|
logger.Warn("--name in the options will be ignored.")
|
||||||
}
|
}
|
||||||
// the runner's own network mode was put into copts above, so ask the flags instead
|
if len(copts.netMode.Value()) > 0 {
|
||||||
if flags.Changed("network") || flags.Changed("net") {
|
|
||||||
logger.Warn("--network and --net in the options will be ignored.")
|
logger.Warn("--network and --net in the options will be ignored.")
|
||||||
}
|
}
|
||||||
hostConfig.NetworkMode = networkMode
|
hostConfig.NetworkMode = networkMode
|
||||||
@@ -670,17 +683,11 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var platSpecs *specs.Platform
|
var platSpecs *specs.Platform
|
||||||
if cr.input.Platform != "" {
|
if cr.input.Platform != "" && supportsContainerImagePlatform(ctx, cr.cli) {
|
||||||
// Dropping the platform silently would build for the host arch.
|
platSpecs, err = parsePlatform(cr.input.Platform)
|
||||||
supported, err := supportsContainerImagePlatform(ctx, cr.cli)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if supported {
|
|
||||||
if platSpecs, err = parsePlatform(cr.input.Platform); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hostConfig := &container.HostConfig{
|
hostConfig := &container.HostConfig{
|
||||||
@@ -932,12 +939,87 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mkdirInContainer creates containerPath and returns it with the symlinked components
|
||||||
|
// replaced by the targets the daemon reports for them. Docker 29.7 rejects tar entries
|
||||||
|
// traversing a symlink to an absolute target, like the "/var/run" of most images, with
|
||||||
|
// "path escapes from parent", and not every daemon creates the implied parents of a
|
||||||
|
// directory entry, so one entry per missing component is extracted at the deepest
|
||||||
|
// existing ancestor.
|
||||||
|
// WORKAROUND: https://github.com/moby/moby/issues/53258
|
||||||
|
func (cr *containerReference) mkdirInContainer(ctx context.Context, containerPath string) (string, error) {
|
||||||
|
parts := strings.Split(strings.Trim(path.Clean(containerPath), "/"), "/")
|
||||||
|
existing := "/"
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
stat, err := cr.cli.ContainerStatPath(ctx, cr.id, client.ContainerStatPathOptions{Path: path.Join(existing, part)})
|
||||||
|
if err != nil {
|
||||||
|
// nothing below exists either, so create the remaining components
|
||||||
|
return path.Join(existing, path.Join(parts[i:]...)), cr.mkdirEntries(ctx, existing, parts[i:])
|
||||||
|
}
|
||||||
|
existing = path.Join(existing, part)
|
||||||
|
if target := stat.Stat.LinkTarget; target != "" {
|
||||||
|
if !path.IsAbs(target) {
|
||||||
|
target = path.Join(path.Dir(existing), target)
|
||||||
|
}
|
||||||
|
existing = target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cr *containerReference) mkdirEntries(ctx context.Context, destPath string, missing []string) error {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
tw := tar.NewWriter(buf)
|
||||||
|
for i := range missing {
|
||||||
|
_ = tw.WriteHeader(&tar.Header{
|
||||||
|
Name: path.Join(missing[:i+1]...),
|
||||||
|
Mode: 0o777,
|
||||||
|
Typeflag: tar.TypeDir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tw.Close()
|
||||||
|
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
||||||
|
DestinationPath: destPath,
|
||||||
|
Content: buf,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||||
|
if cr.id == "" {
|
||||||
|
return cr.missingContainerError("copy to %s", destPath)
|
||||||
|
}
|
||||||
|
destPath, err := cr.mkdirInContainer(ctx, destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
|
||||||
|
}
|
||||||
|
// Copy Content
|
||||||
|
_, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
||||||
|
DestinationPath: destPath,
|
||||||
|
Content: tarStream,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to copy content to container: %w", err)
|
||||||
|
}
|
||||||
|
// If this fails, then folders have wrong permissions on non root container
|
||||||
|
if cr.UID != 0 || cr.GID != 0 {
|
||||||
|
_ = cr.Exec([]string{"chown", "-R", fmt.Sprintf("%d:%d", cr.UID, cr.GID), destPath}, nil, "0", "")(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
return cr.missingContainerError("copy directory to %s", dstPath)
|
||||||
}
|
}
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
dstPath, err := cr.mkdirInContainer(ctx, dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to mkdir to copy directory to container: %w", err)
|
||||||
|
}
|
||||||
tarFile, err := os.CreateTemp("", "act")
|
tarFile, err := os.CreateTemp("", "act")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -973,6 +1055,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
fc := &filecollector.FileCollector{
|
fc := &filecollector.FileCollector{
|
||||||
|
Fs: &filecollector.DefaultFs{},
|
||||||
Ignorer: ignorer,
|
Ignorer: ignorer,
|
||||||
SrcPath: srcPath,
|
SrcPath: srcPath,
|
||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
@@ -1123,64 +1206,74 @@ func (cr *containerReference) wait() common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// sanitizeOptionsHostConfig takes back everything a workflow could escape the container with,
|
// sanitizeOptionsHostConfig clears the HostConfig fields parsed from a
|
||||||
// setting each field to trusted, which is what the runner's own options parse to on their own.
|
// workflow-controlled container.options string that could be used to escape the
|
||||||
// Only for unprivileged mode, since privileged mode grants host access anyway.
|
// container when privileged mode is disabled. It must only be called when the
|
||||||
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig, trusted *container.HostConfig) {
|
// runner has privileged mode turned off; with privileged mode enabled the
|
||||||
resetOption(logger, "--pid", &hostConfig.PidMode, trusted.PidMode)
|
// administrator has already opted into host access.
|
||||||
resetOption(logger, "--ipc", &hostConfig.IpcMode, trusted.IpcMode)
|
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig *container.HostConfig) {
|
||||||
resetOption(logger, "--uts", &hostConfig.UTSMode, trusted.UTSMode)
|
warn := func(option string) {
|
||||||
resetOption(logger, "--cgroupns", &hostConfig.CgroupnsMode, trusted.CgroupnsMode)
|
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", option)
|
||||||
resetOption(logger, "--userns", &hostConfig.UsernsMode, trusted.UsernsMode) // --userns=host would undo the remapping the runner asked for
|
}
|
||||||
resetOption(logger, "--cap-add", &hostConfig.CapAdd, trusted.CapAdd)
|
|
||||||
resetOption(logger, "--security-opt", &hostConfig.SecurityOpt, trusted.SecurityOpt)
|
|
||||||
resetOption(logger, "--device", &hostConfig.Devices, trusted.Devices)
|
|
||||||
resetOption(logger, "--device-cgroup-rule", &hostConfig.DeviceCgroupRules, trusted.DeviceCgroupRules)
|
|
||||||
resetOption(logger, "--gpus", &hostConfig.DeviceRequests, trusted.DeviceRequests)
|
|
||||||
resetOption(logger, "--volumes-from", &hostConfig.VolumesFrom, trusted.VolumesFrom)
|
|
||||||
resetOption(logger, "--runtime", &hostConfig.Runtime, trusted.Runtime)
|
|
||||||
resetOption(logger, "--cgroup-parent", &hostConfig.CgroupParent, trusted.CgroupParent)
|
|
||||||
resetOption(logger, "--sysctl", &hostConfig.Sysctls, trusted.Sysctls)
|
|
||||||
resetOption(logger, "--isolation", &hostConfig.Isolation, trusted.Isolation) // windows: process isolation drops the hyper-v boundary
|
|
||||||
resetOption(logger, "--volume-driver", &hostConfig.VolumeDriver, trusted.VolumeDriver)
|
|
||||||
// systempaths=unconfined lands in these two rather than in SecurityOpt
|
|
||||||
resetOption(logger, "--security-opt", &hostConfig.MaskedPaths, trusted.MaskedPaths)
|
|
||||||
resetOption(logger, "--security-opt", &hostConfig.ReadonlyPaths, trusted.ReadonlyPaths)
|
|
||||||
|
|
||||||
// a driver mounts what it likes, e.g. local with device= binds any host path, which
|
if hostConfig.PidMode != "" {
|
||||||
// valid_volumes never gets to see
|
warn("--pid")
|
||||||
hostConfig.Mounts = slices.DeleteFunc(hostConfig.Mounts, func(mt mount.Mount) bool {
|
hostConfig.PidMode = ""
|
||||||
if mt.VolumeOptions == nil || mt.VolumeOptions.DriverConfig == nil ||
|
|
||||||
slices.ContainsFunc(trusted.Mounts, func(t mount.Mount) bool { return reflect.DeepEqual(t, mt) }) {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
logger.Warnf("volume driver of %q in the workflow is not allowed when privileged mode is disabled and will be ignored", mt.Source)
|
if hostConfig.IpcMode != "" {
|
||||||
return true
|
warn("--ipc")
|
||||||
})
|
hostConfig.IpcMode = ""
|
||||||
}
|
|
||||||
|
|
||||||
// resetOption puts a field back to the runner's own value. It compares the values rather than
|
|
||||||
// the flags, so a field that more than one option feeds cannot slip through.
|
|
||||||
func resetOption[T any](logger logrus.FieldLogger, option string, field *T, trusted T) {
|
|
||||||
if reflect.DeepEqual(*field, trusted) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
logger.Warnf("container option %q in the workflow is not allowed when privileged mode is disabled and will be ignored", option)
|
if hostConfig.UTSMode != "" {
|
||||||
*field = trusted
|
warn("--uts")
|
||||||
}
|
hostConfig.UTSMode = ""
|
||||||
|
|
||||||
// parseOptionsHostConfig parses one options string on its own, to see what it alone asks for.
|
|
||||||
// Even "" goes through the parser, or its empty slices and maps would differ from a real parse.
|
|
||||||
func parseOptionsHostConfig(options string) (*container.HostConfig, error) {
|
|
||||||
flags, copts, _, err := parseContainerOptions(options)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
if hostConfig.CgroupnsMode != "" {
|
||||||
if err != nil {
|
warn("--cgroupns")
|
||||||
return nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
hostConfig.CgroupnsMode = ""
|
||||||
|
}
|
||||||
|
// UsernsMode is set from the runner-controlled input; never let options
|
||||||
|
// override it (e.g. --userns=host disables user namespace remapping).
|
||||||
|
if hostConfig.UsernsMode != "" {
|
||||||
|
warn("--userns")
|
||||||
|
hostConfig.UsernsMode = ""
|
||||||
|
}
|
||||||
|
if len(hostConfig.CapAdd) > 0 {
|
||||||
|
warn("--cap-add")
|
||||||
|
hostConfig.CapAdd = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.SecurityOpt) > 0 {
|
||||||
|
warn("--security-opt")
|
||||||
|
hostConfig.SecurityOpt = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.Devices) > 0 {
|
||||||
|
warn("--device")
|
||||||
|
hostConfig.Devices = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.DeviceCgroupRules) > 0 {
|
||||||
|
warn("--device-cgroup-rule")
|
||||||
|
hostConfig.DeviceCgroupRules = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.DeviceRequests) > 0 {
|
||||||
|
warn("--gpus")
|
||||||
|
hostConfig.DeviceRequests = nil
|
||||||
|
}
|
||||||
|
if len(hostConfig.VolumesFrom) > 0 {
|
||||||
|
warn("--volumes-from")
|
||||||
|
hostConfig.VolumesFrom = nil
|
||||||
|
}
|
||||||
|
if hostConfig.Runtime != "" {
|
||||||
|
warn("--runtime")
|
||||||
|
hostConfig.Runtime = ""
|
||||||
|
}
|
||||||
|
if hostConfig.CgroupParent != "" {
|
||||||
|
warn("--cgroup-parent")
|
||||||
|
hostConfig.CgroupParent = ""
|
||||||
|
}
|
||||||
|
if len(hostConfig.Sysctls) > 0 {
|
||||||
|
warn("--sysctl")
|
||||||
|
hostConfig.Sysctls = nil
|
||||||
}
|
}
|
||||||
return containerConfig.HostConfig, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
|
|||||||
+202
-139
@@ -5,6 +5,7 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -77,11 +79,6 @@ type mockDockerClient struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ServerVersion(ctx context.Context, opts mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) {
|
|
||||||
args := m.Called(ctx, opts)
|
|
||||||
return args.Get(0).(mobyclient.ServerVersionResult), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
||||||
@@ -97,6 +94,11 @@ func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts
|
|||||||
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, containerID string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
||||||
|
args := m.Called(ctx, containerID, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
||||||
args := m.Called(ctx, containerID, opts)
|
args := m.Called(ctx, containerID, opts)
|
||||||
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
||||||
@@ -147,17 +149,12 @@ func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mo
|
|||||||
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
|
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
type interruptReader struct {
|
type endlessReader struct {
|
||||||
started chan struct{}
|
io.Reader
|
||||||
interrupted chan struct{}
|
|
||||||
stopped chan struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *interruptReader) Read(_ []byte) (int, error) {
|
func (r endlessReader) Read(_ []byte) (n int, err error) {
|
||||||
close(r.started)
|
return 1, nil
|
||||||
<-r.interrupted
|
|
||||||
close(r.stopped)
|
|
||||||
return 0, io.EOF
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockConn struct {
|
type mockConn struct {
|
||||||
@@ -177,17 +174,16 @@ func (m *mockConn) Close() (err error) {
|
|||||||
func TestDockerExecAbort(t *testing.T) {
|
func TestDockerExecAbort(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
reader := &interruptReader{started: make(chan struct{}), interrupted: make(chan struct{}), stopped: make(chan struct{})}
|
|
||||||
conn := &mockConn{}
|
conn := &mockConn{}
|
||||||
conn.On("Write", []byte{3}).
|
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
||||||
Run(func(mock.Arguments) { close(reader.interrupted) }).
|
|
||||||
Return(1, nil)
|
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||||
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
Conn: conn,
|
Conn: conn,
|
||||||
Reader: bufio.NewReader(reader),
|
Reader: bufio.NewReader(endlessReader{}),
|
||||||
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
@@ -204,11 +200,11 @@ func TestDockerExecAbort(t *testing.T) {
|
|||||||
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
<-reader.started
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
err := <-channel
|
err := <-channel
|
||||||
<-reader.stopped
|
|
||||||
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
conn.AssertExpectations(t)
|
conn.AssertExpectations(t)
|
||||||
@@ -223,8 +219,10 @@ func TestDockerExecFailure(t *testing.T) {
|
|||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||||
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
Conn: conn,
|
Conn: conn,
|
||||||
Reader: bufio.NewReader(strings.NewReader("output")),
|
Reader: bufio.NewReader(strings.NewReader("output")),
|
||||||
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
|
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
|
||||||
ExitCode: 1,
|
ExitCode: 1,
|
||||||
@@ -276,8 +274,10 @@ func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
|||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
||||||
Return(mobyclient.ContainerAttachResult{
|
Return(mobyclient.ContainerAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
Conn: &mockConn{},
|
Conn: &mockConn{},
|
||||||
Reader: bufio.NewReader(framed),
|
Reader: bufio.NewReader(framed),
|
||||||
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
statusCh := make(chan container.WaitResponse, 1)
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
@@ -342,6 +342,88 @@ func TestDockerWaitFailure(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stubStatPath answers path resolution: the given paths exist, mapped to their target
|
||||||
|
// when they are a symlink, everything else does not exist.
|
||||||
|
func stubStatPath(client *mockDockerClient, existing map[string]string) {
|
||||||
|
for containerPath, target := range existing {
|
||||||
|
client.On("ContainerStatPath", mock.Anything, "123", mobyclient.ContainerStatPathOptions{Path: containerPath}).
|
||||||
|
Return(mobyclient.ContainerStatPathResult{Stat: container.PathStat{LinkTarget: target}}, nil).Maybe()
|
||||||
|
}
|
||||||
|
client.On("ContainerStatPath", mock.Anything, "123", mock.Anything).
|
||||||
|
Return(mobyclient.ContainerStatPathResult{}, cerrdefs.ErrNotFound).Maybe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mkdir tarball is extracted at the deepest existing ancestor, with entries relative
|
||||||
|
// to it that never traverse the "/var/run" symlink, see moby/moby#53258.
|
||||||
|
func TestDockerCopyTarStream(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var mkdirNames []string
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
stubStatPath(client, map[string]string{"/var": "", "/var/run": "/run", "/run": ""})
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
if opts.DestinationPath != "/run" || opts.Content == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(opts.Content)
|
||||||
|
for hdr, err := tr.Next(); err == nil; hdr, err = tr.Next() {
|
||||||
|
mkdirNames = append(mkdirNames, hdr.Name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/run/act" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||||
|
assert.Equal(t, []string{"act"}, mkdirNames)
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerCopyTarStreamErrors(t *testing.T) {
|
||||||
|
merr := errors.New("Failure")
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
mkdirErr error
|
||||||
|
copyErr error
|
||||||
|
}{
|
||||||
|
{"mkdir", merr, nil},
|
||||||
|
{"copy content", nil, merr},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
stubStatPath(client, map[string]string{"/var": "", "/var/run": ""})
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/var/run" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, testCase.mkdirErr)
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, testCase.copyErr).Maybe()
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.ErrorIs(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}), merr)
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||||
// be logged as one.
|
// be logged as one.
|
||||||
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||||
@@ -472,6 +554,7 @@ func TestRejectsMissingContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
||||||
|
check("CopyTarStream", cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||||
check("GetContainerArchive", err)
|
check("GetContainerArchive", err)
|
||||||
@@ -507,6 +590,34 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDockerCopyToSymlinkPath is a regression test for gitea/runner#981. Most base images
|
||||||
|
// symlink /var/run to /run, so copying into /var/run/act traverses that symlink. The broken
|
||||||
|
// docker 29.5.1 daemon fails the extraction with "mkdirat var/run: file exists" (fixed in
|
||||||
|
// 29.5.2). Running against the daemon shipped in the dind image, this catches a bad bump.
|
||||||
|
func TestDockerCopyToSymlinkPath(t *testing.T) {
|
||||||
|
requireDocker(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rc := NewContainer(&NewContainerInput{
|
||||||
|
Image: "alpine:latest",
|
||||||
|
Entrypoint: []string{"sleep", "30"},
|
||||||
|
Name: "act-test-symlink-" + time.Now().Format("20060102150405.000000"),
|
||||||
|
AutoRemove: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, rc.Pull(false)(ctx))
|
||||||
|
require.NoError(t, rc.Create(nil, nil)(ctx))
|
||||||
|
require.NoError(t, rc.Start(false)(ctx))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = rc.Remove()(ctx)
|
||||||
|
_ = rc.Close()(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
// CopyTarStream resolves the var/run symlink and creates act below its target, the
|
||||||
|
// exact step that fails on a broken daemon.
|
||||||
|
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Type assert containerReference implements ExecutionsEnvironment
|
// Type assert containerReference implements ExecutionsEnvironment
|
||||||
var _ ExecutionsEnvironment = &containerReference{}
|
var _ ExecutionsEnvironment = &containerReference{}
|
||||||
|
|
||||||
@@ -583,59 +694,11 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A volume driver decides for itself what it mounts, e.g. the local driver with device= binds
|
|
||||||
// any host path, which valid_volumes never gets to see.
|
|
||||||
func TestMergeContainerConfigsDropsVolumeDriversFromWorkflows(t *testing.T) {
|
|
||||||
const escape = "--mount type=volume,src=job-escape,dst=/host,volume-driver=local,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=/"
|
|
||||||
|
|
||||||
hostConfig, _ := mergeOptions(t, "", escape+" --mount type=volume,src=job-plain,dst=/cache", false)
|
|
||||||
require.Len(t, hostConfig.Mounts, 1)
|
|
||||||
assert.Equal(t, "job-plain", hostConfig.Mounts[0].Source)
|
|
||||||
|
|
||||||
// the same mount from the runner's own options is the administrator's to make
|
|
||||||
hostConfig, _ = mergeOptions(t, escape, "", false)
|
|
||||||
require.Len(t, hostConfig.Mounts, 1)
|
|
||||||
assert.Equal(t, "job-escape", hostConfig.Mounts[0].Source)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both of these are read here, on the runner, so a workflow could read the runner's files
|
|
||||||
// and environment with them.
|
|
||||||
func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
|
|
||||||
hostFile := filepath.Join(t.TempDir(), "host.env")
|
|
||||||
require.NoError(t, os.WriteFile(hostFile, []byte("STOLEN=from-the-host\n"), 0o600))
|
|
||||||
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
|
||||||
|
|
||||||
for _, option := range []string{"--env-file " + hostFile, "--label-file " + hostFile} {
|
|
||||||
logger, _ := test.NewNullLogger()
|
|
||||||
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
|
|
||||||
|
|
||||||
_, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
|
||||||
require.ErrorContains(t, err, "not allowed in a workflow")
|
|
||||||
|
|
||||||
// the runner reading its own files is what those options are for
|
|
||||||
cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}}
|
|
||||||
_, _, err = cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// a bare name is no longer resolved from the runner's environment, for either source
|
|
||||||
logger, _ := test.NewNullLogger()
|
|
||||||
cr := &containerReference{input: &NewContainerInput{
|
|
||||||
NetworkMode: "bridge",
|
|
||||||
RunnerOptions: "--env RUNNER_SECRET",
|
|
||||||
WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value",
|
|
||||||
}}
|
|
||||||
|
|
||||||
config, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger, _ := test.NewNullLogger()
|
||||||
|
|
||||||
// every field the sanitizer resets, so a reset dropped in a refactor fails here
|
dangerous := func() *container.HostConfig {
|
||||||
hostConfig := &container.HostConfig{
|
return &container.HostConfig{
|
||||||
PidMode: "host",
|
PidMode: "host",
|
||||||
IpcMode: "host",
|
IpcMode: "host",
|
||||||
UTSMode: "host",
|
UTSMode: "host",
|
||||||
@@ -645,71 +708,94 @@ func TestSanitizeOptionsHostConfig(t *testing.T) {
|
|||||||
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
||||||
VolumesFrom: []string{"other"},
|
VolumesFrom: []string{"other"},
|
||||||
Runtime: "runc",
|
Runtime: "runc",
|
||||||
Isolation: "process",
|
Resources: container.Resources{
|
||||||
VolumeDriver: "rogue",
|
|
||||||
MaskedPaths: []string{},
|
|
||||||
ReadonlyPaths: []string{},
|
|
||||||
CgroupParent: "/custom",
|
CgroupParent: "/custom",
|
||||||
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
||||||
DeviceCgroupRules: []string{"a *:* rwm"},
|
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||||
DeviceRequests: []container.DeviceRequest{{Count: -1, Capabilities: [][]string{{"gpu"}}}},
|
},
|
||||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sanitizeOptionsHostConfig(logger, hostConfig, &container.HostConfig{})
|
hostConfig := dangerous()
|
||||||
|
sanitizeOptionsHostConfig(logger, hostConfig)
|
||||||
|
|
||||||
assert.Equal(t, &container.HostConfig{}, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeOptions merges both option sources into a bare container, returning the result and its log.
|
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
||||||
func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *test.Hook) {
|
// OS-independent options only: --device parsing requires a linux/windows
|
||||||
t.Helper()
|
// server OS, which is not guaranteed for the test host.
|
||||||
logger, hook := test.NewNullLogger()
|
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||||
cr := &containerReference{input: &NewContainerInput{
|
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||||
RunnerOptions: runnerOptions,
|
"--security-opt apparmor=unconfined --volumes-from other " +
|
||||||
WorkflowOptions: workflowOptions,
|
"--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",
|
NetworkMode: "bridge",
|
||||||
UsernsMode: "private",
|
UsernsMode: "private",
|
||||||
}}
|
},
|
||||||
|
}
|
||||||
|
|
||||||
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
|
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||||
Privileged: privileged,
|
Privileged: false,
|
||||||
UsernsMode: container.UsernsMode("private"),
|
UsernsMode: container.UsernsMode("private"),
|
||||||
NetworkMode: container.NetworkMode("bridge"),
|
NetworkMode: container.NetworkMode("bridge"),
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return hostConfig, hook
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
assert.False(t, hostConfig.Privileged)
|
||||||
// OS-independent options only, --device and --gpus need a linux/windows server OS
|
assert.Empty(t, string(hostConfig.PidMode))
|
||||||
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
assert.Empty(t, string(hostConfig.IpcMode))
|
||||||
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
assert.Empty(t, string(hostConfig.UTSMode))
|
||||||
"--security-opt apparmor=unconfined --volumes-from other --isolation process " +
|
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
// 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)
|
||||||
|
})
|
||||||
|
|
||||||
// whatever the workflow adds, an unprivileged container comes out exactly as the runner's
|
t.Run("privileged preserves options", func(t *testing.T) {
|
||||||
// own options alone describe it, field for field
|
logger, _ := test.NewNullLogger()
|
||||||
for _, runnerOptions := range []string{"--shm-size 1g", dangerousOptions, "--cap-add SYS_ADMIN --security-opt seccomp=unconfined"} {
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
runnerOnly, _ := mergeOptions(t, runnerOptions, "", false)
|
cr := &containerReference{
|
||||||
withWorkflow, _ := mergeOptions(t, runnerOptions, dangerousOptions, false)
|
input: &NewContainerInput{
|
||||||
|
Options: "--pid=host --cap-add=ALL --security-opt seccomp=unconfined",
|
||||||
assert.Equal(t, runnerOnly, withWorkflow, "runner options: %q", runnerOptions)
|
NetworkMode: "bridge",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// the same options from the runner reach the daemon, even --userns, which no workflow may set
|
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||||
kept, _ := mergeOptions(t, dangerousOptions, "", false)
|
Privileged: true,
|
||||||
assert.Equal(t, "host", string(kept.PidMode))
|
NetworkMode: container.NetworkMode("bridge"),
|
||||||
assert.Equal(t, []string{"ALL"}, kept.CapAdd)
|
})
|
||||||
assert.Equal(t, "runc", kept.Runtime)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "host", string(kept.UsernsMode))
|
|
||||||
assert.False(t, kept.Privileged)
|
|
||||||
|
|
||||||
// privileged is the administrator opting in, so the workflow's options are honored
|
assert.Equal(t, "host", string(hostConfig.PidMode))
|
||||||
privileged, _ := mergeOptions(t, "", dangerousOptions, true)
|
assert.Equal(t, []string{"ALL"}, hostConfig.CapAdd)
|
||||||
assert.Equal(t, "host", string(privileged.PidMode))
|
assert.Equal(t, []string{"seccomp=unconfined"}, hostConfig.SecurityOpt)
|
||||||
assert.Equal(t, []string{"ALL"}, privileged.CapAdd)
|
})
|
||||||
assert.Equal(t, []string{"seccomp=unconfined", "apparmor=unconfined"}, privileged.SecurityOpt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||||
@@ -808,7 +894,7 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
|||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
input: &NewContainerInput{
|
input: &NewContainerInput{
|
||||||
NetworkMode: "bridge",
|
NetworkMode: "bridge",
|
||||||
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
|
Options: "--volume /host/tools:/opt/hostedtoolcache",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,26 +906,3 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
||||||
assert.Empty(t, hostConf.Mounts)
|
assert.Empty(t, hostConf.Mounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
|
||||||
warnings := func(runnerOptions, workflowOptions string) int {
|
|
||||||
_, hook := mergeOptions(t, runnerOptions, workflowOptions, false)
|
|
||||||
return len(hook.AllEntries())
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
|
||||||
assert.Zero(t, warnings("", "--shm-size 1g"))
|
|
||||||
assert.Equal(t, 1, warnings("--network host", ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A dead daemon must fail the job, not panic through logrus and not silently
|
|
||||||
// drop the requested platform.
|
|
||||||
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
|
||||||
cli := &mockDockerClient{}
|
|
||||||
cli.On("ServerVersion", mock.Anything, mock.Anything).
|
|
||||||
Return(mobyclient.ServerVersionResult{}, errors.New("cannot connect to the Docker daemon"))
|
|
||||||
|
|
||||||
supported, err := supportsContainerImagePlatform(t.Context(), cli)
|
|
||||||
require.ErrorContains(t, err, "cannot connect to the Docker daemon")
|
|
||||||
assert.False(t, supported)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var CommonSocketLocations = []string{
|
||||||
|
"/var/run/docker.sock",
|
||||||
|
"/run/podman/podman.sock",
|
||||||
|
"$HOME/.colima/docker.sock",
|
||||||
|
"$XDG_RUNTIME_DIR/docker.sock",
|
||||||
|
"$XDG_RUNTIME_DIR/podman/podman.sock",
|
||||||
|
`\\.\pipe\docker_engine`,
|
||||||
|
"$HOME/.docker/run/docker.sock",
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns socket URI or false if not found any
|
||||||
|
func socketLocation() (string, bool) {
|
||||||
|
if dockerHost, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
||||||
|
return dockerHost, true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range CommonSocketLocations {
|
||||||
|
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
|
||||||
|
if strings.HasPrefix(p, `\\.\`) {
|
||||||
|
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
||||||
|
}
|
||||||
|
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function, `isDockerHostURI`, takes a string argument `daemonPath`. It checks if the
|
||||||
|
// `daemonPath` is a valid Docker host URI. It does this by checking if the scheme of the URI (the
|
||||||
|
// part before "://") contains only alphabetic characters. If it does, the function returns true,
|
||||||
|
// indicating that the `daemonPath` is a Docker host URI. If it doesn't, or if the "://" delimiter
|
||||||
|
// is not found in the `daemonPath`, the function returns false.
|
||||||
|
func isDockerHostURI(daemonPath string) bool {
|
||||||
|
if before, _, ok := strings.Cut(daemonPath, "://"); ok {
|
||||||
|
scheme := before
|
||||||
|
if strings.IndexFunc(scheme, func(r rune) bool {
|
||||||
|
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z')
|
||||||
|
}) == -1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocketAndHost struct {
|
||||||
|
Socket string
|
||||||
|
Host string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSocketAndHost(containerSocket string) (SocketAndHost, error) {
|
||||||
|
log.Debugf("Handling container host and socket")
|
||||||
|
|
||||||
|
// Prefer DOCKER_HOST, don't override it
|
||||||
|
dockerHost, hasDockerHost := socketLocation()
|
||||||
|
socketHost := SocketAndHost{Socket: containerSocket, Host: dockerHost}
|
||||||
|
|
||||||
|
// ** socketHost.Socket cases **
|
||||||
|
// Case 1: User does _not_ want to mount a daemon socket (passes a dash)
|
||||||
|
// Case 2: User passes a filepath to the socket; is that even valid?
|
||||||
|
// Case 3: User passes a valid socket; do nothing
|
||||||
|
// Case 4: User omitted the flag; set a sane default
|
||||||
|
|
||||||
|
// ** DOCKER_HOST cases **
|
||||||
|
// Case A: DOCKER_HOST is set; use it, i.e. do nothing
|
||||||
|
// Case B: DOCKER_HOST is empty; use sane defaults
|
||||||
|
|
||||||
|
// Set host for sanity's sake, when the socket isn't useful
|
||||||
|
if !hasDockerHost && (socketHost.Socket == "-" || !isDockerHostURI(socketHost.Socket) || socketHost.Socket == "") {
|
||||||
|
// Cases: 1B, 2B, 4B
|
||||||
|
socket, found := socketLocation()
|
||||||
|
socketHost.Host = socket
|
||||||
|
hasDockerHost = found
|
||||||
|
}
|
||||||
|
|
||||||
|
// A - (dash) in socketHost.Socket means don't mount, preserve this value
|
||||||
|
// otherwise if socketHost.Socket is a filepath don't use it as socket
|
||||||
|
// Exit early if we're in an invalid state (e.g. when no DOCKER_HOST and user supplied "-", a dash or omitted)
|
||||||
|
if !hasDockerHost && socketHost.Socket != "" && !isDockerHostURI(socketHost.Socket) {
|
||||||
|
// Cases: 1B, 2B
|
||||||
|
// Should we early-exit here, since there is no host nor socket to talk to?
|
||||||
|
return SocketAndHost{}, fmt.Errorf("DOCKER_HOST was not set, couldn't be found in the usual locations, and the container daemon socket ('%s') is invalid", socketHost.Socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to DOCKER_HOST if set
|
||||||
|
if socketHost.Socket == "" && hasDockerHost {
|
||||||
|
// Cases: 4A
|
||||||
|
log.Debugf("Defaulting container socket to DOCKER_HOST")
|
||||||
|
socketHost.Socket = socketHost.Host
|
||||||
|
}
|
||||||
|
// Set sane default socket location if user omitted it
|
||||||
|
if socketHost.Socket == "" {
|
||||||
|
// Cases: 4B
|
||||||
|
socket, _ := socketLocation()
|
||||||
|
// socket is empty if it isn't found, so assignment here is at worst a no-op
|
||||||
|
log.Debugf("Defaulting container socket to default '%s'", socket)
|
||||||
|
socketHost.Socket = socket
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if both the DOCKER_HOST and socket are fulfilled
|
||||||
|
if hasDockerHost {
|
||||||
|
// Cases: 1A, 2A, 3A, 4A
|
||||||
|
if !isDockerHostURI(socketHost.Socket) {
|
||||||
|
// Cases: 1A, 2A
|
||||||
|
log.Debugf("DOCKER_HOST is set, but socket is invalid '%s'", socketHost.Socket)
|
||||||
|
}
|
||||||
|
return socketHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a sane DOCKER_HOST default if we can
|
||||||
|
if isDockerHostURI(socketHost.Socket) {
|
||||||
|
// Cases: 3B
|
||||||
|
log.Debugf("Setting DOCKER_HOST to container socket '%s'", socketHost.Socket)
|
||||||
|
socketHost.Host = socketHost.Socket
|
||||||
|
// Both DOCKER_HOST and container socket are valid; short-circuit exit
|
||||||
|
return socketHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here there is no DOCKER_HOST _and_ the supplied container socket is not a valid URI (either invalid or a file path)
|
||||||
|
// Cases: 2B <- but is already handled at the top
|
||||||
|
// I.e. this path should never be taken
|
||||||
|
return SocketAndHost{}, fmt.Errorf("no DOCKER_HOST and an invalid container socket '%s'", socketHost.Socket)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
assert "github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var originalCommonSocketLocations = CommonSocketLocations
|
||||||
|
|
||||||
|
func isolateSocketEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
|
||||||
|
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
|
||||||
|
t.Setenv("DOCKER_HOST", host)
|
||||||
|
} else {
|
||||||
|
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
|
socketURI := "/path/to/my.socket"
|
||||||
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostNoSocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost("")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
|
socketURI := "/path/to/my.socket"
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
defaultSocket, defaultSocketFound := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.True(t, defaultSocketFound, "Expected to find default socket")
|
||||||
|
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
|
||||||
|
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost("-")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost("")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.True(t, found, "Expected a default socket to be found")
|
||||||
|
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch
|
||||||
|
// > Your code breaks setting DOCKER_HOST if shouldMount is false.
|
||||||
|
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
||||||
|
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
|
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
||||||
|
mySocket := mySocketFile.Name()
|
||||||
|
unixSocket := "unix://" + mySocket
|
||||||
|
defer os.RemoveAll(mySocket)
|
||||||
|
assert.NoError(t, tmpErr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
|
||||||
|
CommonSocketLocations = []string{mySocket}
|
||||||
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost("")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
|
||||||
|
assert.True(t, found, "Expected default socket to be found")
|
||||||
|
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
mySocket := "/my/socket/path.sock"
|
||||||
|
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
||||||
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost(mySocket)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.False(t, found, "Expected no default socket to be found")
|
||||||
|
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
|
||||||
|
assert.Error(t, err, "Expected an error in invalid state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
||||||
|
// Arrange
|
||||||
|
isolateSocketEnv(t)
|
||||||
|
socketURI := "unix:///path/to/my.socket"
|
||||||
|
CommonSocketLocations = []string{"/unusual", "/location"}
|
||||||
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Default socket locations
|
||||||
|
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.False(t, found, "Expected no default socket to be found")
|
||||||
|
// Sane default
|
||||||
|
assert.NoError(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/lookpath"
|
"gitea.com/gitea/runner/act/lookpath"
|
||||||
"gitea.com/gitea/runner/internal/pkg/process"
|
"gitea.com/gitea/runner/internal/pkg/process"
|
||||||
|
|
||||||
"github.com/creack/pty"
|
|
||||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||||
"github.com/go-git/go-billy/v5/osfs"
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
@@ -72,6 +71,12 @@ func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) ConnectToNetwork(name string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) Close() common.Executor {
|
func (e *HostEnvironment) Close() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
@@ -92,6 +97,33 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||||
|
if err := os.RemoveAll(destPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(tarStream)
|
||||||
|
cp := &filecollector.CopyCollector{
|
||||||
|
DstDir: destPath,
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
ti, err := tr.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ti.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return errors.New("CopyTarStream has been cancelled")
|
||||||
|
}
|
||||||
|
if err := cp.WriteFile(ti.Name, ti.FileInfo(), ti.Linkname, tr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
@@ -110,6 +142,7 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
|
|||||||
ignorer = gitignore.NewMatcher(ps)
|
ignorer = gitignore.NewMatcher(ps)
|
||||||
}
|
}
|
||||||
fc := &filecollector.FileCollector{
|
fc := &filecollector.FileCollector{
|
||||||
|
Fs: &filecollector.DefaultFs{},
|
||||||
Ignorer: ignorer,
|
Ignorer: ignorer,
|
||||||
SrcPath: srcPath,
|
SrcPath: srcPath,
|
||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
@@ -147,6 +180,7 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
|
|||||||
srcPrefix += string(filepath.Separator)
|
srcPrefix += string(filepath.Separator)
|
||||||
}
|
}
|
||||||
fc := &filecollector.FileCollector{
|
fc := &filecollector.FileCollector{
|
||||||
|
Fs: &filecollector.DefaultFs{},
|
||||||
SrcPath: srcPath,
|
SrcPath: srcPath,
|
||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
Handler: tc,
|
Handler: tc,
|
||||||
@@ -212,8 +246,24 @@ func (w *ptyWriter) Write(buf []byte) (int, error) {
|
|||||||
return w.Out.Write(buf)
|
return w.Out.Write(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type localEnv struct {
|
||||||
|
env map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *localEnv) Getenv(name string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for k, v := range l.env {
|
||||||
|
if strings.EqualFold(name, k) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.env[name]
|
||||||
|
}
|
||||||
|
|
||||||
func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) {
|
func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) {
|
||||||
f, err := lookpath.LookPath2(cmd, env)
|
f, err := lookpath.LookPath2(cmd, &localEnv{env: env})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err := "Cannot find: " + cmd + " in PATH"
|
err := "Cannot find: " + cmd + " in PATH"
|
||||||
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
|
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
|
||||||
@@ -225,7 +275,7 @@ func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||||
ppty, tty, err := pty.Open()
|
ppty, tty, err := openPty()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -351,7 +401,8 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
}
|
}
|
||||||
err = cmd.Wait()
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
return ExitCodeError(exitErr.ExitCode())
|
return ExitCodeError(exitErr.ExitCode())
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -46,10 +46,9 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
}
|
}
|
||||||
singleLineEnv := strings.Index(line, "=")
|
singleLineEnv := strings.Index(line, "=")
|
||||||
multiLineEnv := strings.Index(line, "<<")
|
multiLineEnv := strings.Index(line, "<<")
|
||||||
switch {
|
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
|
||||||
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
|
|
||||||
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
||||||
case multiLineEnv != -1:
|
} else if multiLineEnv != -1 {
|
||||||
multiLineEnvContent := ""
|
multiLineEnvContent := ""
|
||||||
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
||||||
delimiterFound := false
|
delimiterFound := false
|
||||||
@@ -71,7 +70,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
||||||
}
|
}
|
||||||
localEnv[line[:multiLineEnv]] = multiLineEnvContent
|
localEnv[line[:multiLineEnv]] = multiLineEnvContent
|
||||||
default:
|
} else {
|
||||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build (!windows && !plan9 && !openbsd) || (!windows && !plan9 && !mips64)
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openPty() (*os.File, *os.File, error) {
|
||||||
|
return pty.Open()
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openPty() (*os.File, *os.File, error) {
|
||||||
|
return nil, nil, errors.New("Unsupported")
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openPty() (*os.File, *os.File, error) {
|
||||||
|
return nil, nil, errors.New("Unsupported")
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openPty() (*os.File, *os.File, error) {
|
||||||
|
return nil, nil, errors.New("Unsupported")
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package exprparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
|
"github.com/rhysd/actionlint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (impl *interperterImpl) contains(search, item reflect.Value) (bool, error) {
|
||||||
|
switch search.Kind() {
|
||||||
|
case reflect.String, reflect.Int, reflect.Float64, reflect.Bool, reflect.Invalid:
|
||||||
|
return strings.Contains(
|
||||||
|
strings.ToLower(CoerceToString(search)),
|
||||||
|
strings.ToLower(CoerceToString(item)),
|
||||||
|
), nil
|
||||||
|
|
||||||
|
case reflect.Slice:
|
||||||
|
for i := 0; i < search.Len(); i++ {
|
||||||
|
arrayItem := search.Index(i).Elem()
|
||||||
|
result, err := impl.compareValues(arrayItem, item, actionlint.CompareOpNodeKindEq)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEqual, ok := result.(bool); ok && isEqual {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) startsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return strings.HasPrefix(
|
||||||
|
strings.ToLower(CoerceToString(searchString)),
|
||||||
|
strings.ToLower(CoerceToString(searchValue)),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return strings.HasSuffix(
|
||||||
|
strings.ToLower(CoerceToString(searchString)),
|
||||||
|
strings.ToLower(CoerceToString(searchValue)),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
passThrough = iota
|
||||||
|
bracketOpen
|
||||||
|
bracketClose
|
||||||
|
)
|
||||||
|
|
||||||
|
func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
|
||||||
|
input := CoerceToString(str)
|
||||||
|
var output strings.Builder
|
||||||
|
replacementIndex := ""
|
||||||
|
|
||||||
|
state := passThrough
|
||||||
|
for _, character := range input {
|
||||||
|
switch state {
|
||||||
|
case passThrough: // normal buffer output
|
||||||
|
switch character {
|
||||||
|
case '{':
|
||||||
|
state = bracketOpen
|
||||||
|
|
||||||
|
case '}':
|
||||||
|
state = bracketClose
|
||||||
|
|
||||||
|
default:
|
||||||
|
output.WriteRune(character)
|
||||||
|
}
|
||||||
|
|
||||||
|
case bracketOpen: // found {
|
||||||
|
switch character {
|
||||||
|
case '{':
|
||||||
|
output.WriteString("{")
|
||||||
|
replacementIndex = ""
|
||||||
|
state = passThrough
|
||||||
|
|
||||||
|
case '}':
|
||||||
|
index, err := strconv.ParseInt(replacementIndex, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("The following format string is invalid: '%s'", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
replacementIndex = ""
|
||||||
|
|
||||||
|
if len(replaceValue) <= int(index) {
|
||||||
|
return "", fmt.Errorf("The following format string references more arguments than were supplied: '%s'", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.WriteString(CoerceToString(replaceValue[index]))
|
||||||
|
|
||||||
|
state = passThrough
|
||||||
|
|
||||||
|
default:
|
||||||
|
replacementIndex += string(character)
|
||||||
|
}
|
||||||
|
|
||||||
|
case bracketClose: // found }
|
||||||
|
switch character {
|
||||||
|
case '}':
|
||||||
|
output.WriteString("}")
|
||||||
|
replacementIndex = ""
|
||||||
|
state = passThrough
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state != passThrough {
|
||||||
|
switch state {
|
||||||
|
case bracketOpen:
|
||||||
|
return "", fmt.Errorf("Unclosed brackets. The following format string is invalid: '%s'", input)
|
||||||
|
|
||||||
|
case bracketClose:
|
||||||
|
return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) join(array, sep reflect.Value) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
separator := CoerceToString(sep)
|
||||||
|
switch array.Kind() {
|
||||||
|
case reflect.Slice:
|
||||||
|
var items []string
|
||||||
|
for i := 0; i < array.Len(); i++ {
|
||||||
|
items = append(items, CoerceToString(array.Index(i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(items, separator), nil
|
||||||
|
default:
|
||||||
|
return strings.Join([]string{CoerceToString(array)}, separator), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) toJSON(value reflect.Value) (string, error) {
|
||||||
|
if value.Kind() == reflect.Invalid {
|
||||||
|
return "null", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
json, err := json.MarshalIndent(value.Interface(), "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Cannot convert value to JSON. Cause: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(json), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) fromJSON(value reflect.Value) (any, error) {
|
||||||
|
if value.Kind() != reflect.String {
|
||||||
|
return nil, fmt.Errorf("Cannot parse non-string type %v as JSON", value.Kind())
|
||||||
|
}
|
||||||
|
|
||||||
|
var data any
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(value.String()), &data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) hashFiles(paths ...reflect.Value) (string, error) {
|
||||||
|
var ps []gitignore.Pattern
|
||||||
|
|
||||||
|
const cwdPrefix = "." + string(filepath.Separator)
|
||||||
|
const excludeCwdPrefix = "!" + cwdPrefix
|
||||||
|
for _, path := range paths {
|
||||||
|
if path.Kind() == reflect.String {
|
||||||
|
cleanPath := path.String()
|
||||||
|
if strings.HasPrefix(cleanPath, cwdPrefix) {
|
||||||
|
cleanPath = cleanPath[len(cwdPrefix):]
|
||||||
|
} else if strings.HasPrefix(cleanPath, excludeCwdPrefix) {
|
||||||
|
cleanPath = "!" + cleanPath[len(excludeCwdPrefix):]
|
||||||
|
}
|
||||||
|
ps = append(ps, gitignore.ParsePattern(cleanPath, nil))
|
||||||
|
} else {
|
||||||
|
return "", errors.New("Non-string path passed to hashFiles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher := gitignore.NewMatcher(ps)
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
if err := filepath.Walk(impl.config.WorkingDir, func(path string, fi fs.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sansPrefix := strings.TrimPrefix(path, impl.config.WorkingDir+string(filepath.Separator))
|
||||||
|
parts := strings.Split(sansPrefix, string(filepath.Separator))
|
||||||
|
if fi.IsDir() || !matcher.Match(parts, fi.IsDir()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files = append(files, path)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return "", fmt.Errorf("Unable to filepath.Walk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
f, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Unable to os.Open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(hasher, f); err != nil {
|
||||||
|
return "", fmt.Errorf("Unable to io.Copy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("Unable to Close file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
|
||||||
|
needs := job.Needs()
|
||||||
|
|
||||||
|
for _, need := range needs {
|
||||||
|
parentNeeds := impl.getNeedsTransitive(impl.config.Run.Workflow.GetJob(need))
|
||||||
|
needs = append(needs, parentNeeds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return needs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) always() (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) jobSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
jobs := impl.config.Run.Workflow.Jobs
|
||||||
|
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||||
|
|
||||||
|
for _, needs := range jobNeeds {
|
||||||
|
if jobs[needs].NeedsResult() != "success" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobStatus returns the current job status, treating a nil Job context as an
|
||||||
|
// empty status so status-check functions never panic on a nil dereference.
|
||||||
|
func (impl *interperterImpl) jobStatus() string {
|
||||||
|
if impl.env.Job == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return impl.env.Job.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) stepSuccess() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return impl.jobStatus() == "success", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) jobFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
jobs := impl.config.Run.Workflow.Jobs
|
||||||
|
jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
|
||||||
|
|
||||||
|
for _, needs := range jobNeeds {
|
||||||
|
if jobs[needs].NeedsResult() == "failure" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) stepFailure() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return impl.jobStatus() == "failure", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) cancelled() (bool, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return impl.jobStatus() == "cancelled", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package exprparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFunctionContains(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"contains('search', 'item') }}", false, "contains-str-str"},
|
||||||
|
{`cOnTaInS('Hello', 'll') }}`, true, "contains-str-casing"},
|
||||||
|
{`contains('HELLO', 'll') }}`, true, "contains-str-casing"},
|
||||||
|
{`contains('3.141592', 3.14) }}`, true, "contains-str-number"},
|
||||||
|
{`contains(3.141592, '3.14') }}`, true, "contains-number-str"},
|
||||||
|
{`contains(3.141592, 3.14) }}`, true, "contains-number-number"},
|
||||||
|
{`contains(true, 'u') }}`, true, "contains-bool-str"},
|
||||||
|
{`contains(null, '') }}`, true, "contains-null-str"},
|
||||||
|
{`contains(fromJSON('["first","second"]'), 'first') }}`, true, "contains-item"},
|
||||||
|
{`contains(fromJSON('[null,"second"]'), '') }}`, true, "contains-item-null-empty-str"},
|
||||||
|
{`contains(fromJSON('["","second"]'), null) }}`, true, "contains-item-empty-str-null"},
|
||||||
|
{`contains(fromJSON('[true,"second"]'), 'true') }}`, false, "contains-item-bool-arr"},
|
||||||
|
{`contains(fromJSON('["true","second"]'), true) }}`, false, "contains-item-str-bool"},
|
||||||
|
{`contains(fromJSON('[3.14,"second"]'), '3.14') }}`, true, "contains-item-number-str"},
|
||||||
|
{`contains(fromJSON('[3.14,"second"]'), 3.14) }}`, true, "contains-item-number-number"},
|
||||||
|
{`contains(fromJSON('["","second"]'), fromJSON('[]')) }}`, false, "contains-item-str-arr"},
|
||||||
|
{`contains(fromJSON('["","second"]'), fromJSON('{}')) }}`, false, "contains-item-str-obj"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionStartsWith(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"startsWith('search', 'se') }}", true, "startswith-string"},
|
||||||
|
{"startsWith('search', 'sa') }}", false, "startswith-string"},
|
||||||
|
{"startsWith('123search', '123s') }}", true, "startswith-string"},
|
||||||
|
{"startsWith(123, 's') }}", false, "startswith-string"},
|
||||||
|
{"startsWith(123, '12') }}", true, "startswith-string"},
|
||||||
|
{"startsWith('123', 12) }}", true, "startswith-string"},
|
||||||
|
{"startsWith(null, '42') }}", false, "startswith-string"},
|
||||||
|
{"startsWith('null', null) }}", true, "startswith-string"},
|
||||||
|
{"startsWith('null', '') }}", true, "startswith-string"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionEndsWith(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"endsWith('search', 'ch') }}", true, "endsWith-string"},
|
||||||
|
{"endsWith('search', 'sa') }}", false, "endsWith-string"},
|
||||||
|
{"endsWith('search123s', '123s') }}", true, "endsWith-string"},
|
||||||
|
{"endsWith(123, 's') }}", false, "endsWith-string"},
|
||||||
|
{"endsWith(123, '23') }}", true, "endsWith-string"},
|
||||||
|
{"endsWith('123', 23) }}", true, "endsWith-string"},
|
||||||
|
{"endsWith(null, '42') }}", false, "endsWith-string"},
|
||||||
|
{"endsWith('null', null) }}", true, "endsWith-string"},
|
||||||
|
{"endsWith('null', '') }}", true, "endsWith-string"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionJoin(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"join(fromJSON('[\"a\", \"b\"]'), ',')", "a,b", "join-arr"},
|
||||||
|
{"join('string', ',')", "string", "join-str"},
|
||||||
|
{"join(1, ',')", "1", "join-number"},
|
||||||
|
{"join(null, ',')", "", "join-number"},
|
||||||
|
{"join(fromJSON('[\"a\", \"b\", null]'), null)", "ab", "join-number"},
|
||||||
|
{"join(fromJSON('[\"a\", \"b\"]'))", "a,b", "join-number"},
|
||||||
|
{"join(fromJSON('[\"a\", \"b\", null]'), 1)", "a1b1", "join-number"},
|
||||||
|
{"join(fromJSON('[1, true, null]'), '-')", "1-true-", "join-mixed-types"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionToJSON(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"toJSON(env) }}", "{\n \"key\": \"value\"\n}", "toJSON"},
|
||||||
|
{"toJSON(null)", "null", "toJSON-null"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Env: map[string]string{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionFromJSON(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"fromJSON('{\"foo\":\"bar\"}') }}", map[string]any{
|
||||||
|
"foo": "bar",
|
||||||
|
}, "fromJSON"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionHashFiles(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"hashFiles('**/non-extant-files') }}", "", "hash-non-existing-file"},
|
||||||
|
{"hashFiles('**/non-extant-files', '**/more-non-extant-files') }}", "", "hash-multiple-non-existing-files"},
|
||||||
|
{"hashFiles('./for-hashing-1.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-single-file"},
|
||||||
|
{"hashFiles('./for-hashing-*.txt') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"},
|
||||||
|
{"hashFiles('./for-hashing-*.txt', '!./for-hashing-2.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-negative-pattern"},
|
||||||
|
{"hashFiles('./for-hashing-**') }}", "c418ba693753c84115ced0da77f876cddc662b9054f4b129b90f822597ee2f94", "hash-multiple-files-and-directories"},
|
||||||
|
{"hashFiles('./for-hashing-3/**') }}", "6f5696b546a7a9d6d42a449dc9a56bef244aaa826601ef27466168846139d2c2", "hash-nested-directories"},
|
||||||
|
{"hashFiles('./for-hashing-3/**/nested-data.txt') }}", "8ecadfb49f7f978d0a9f3a957e9c8da6cc9ab871f5203b5d9f9d1dc87d8af18c", "hash-nested-directories-2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
workdir, err := filepath.Abs("testdata")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFunctionFormat(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
error any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"format('text')", "text", nil, "format-plain-string"},
|
||||||
|
{"format('Hello {0} {1} {2}!', 'Mona', 'the', 'Octocat')", "Hello Mona the Octocat!", nil, "format-with-placeholders"},
|
||||||
|
{"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "{Hello Mona the Octocat!}", nil, "format-with-escaped-braces"},
|
||||||
|
{"format('{{0}}', 'test')", "{0}", nil, "format-with-escaped-braces"},
|
||||||
|
{"format('{{{0}}}', 'test')", "{test}", nil, "format-with-escaped-braces-and-value"},
|
||||||
|
{"format('}}')", "}", nil, "format-output-closing-brace"},
|
||||||
|
{`format('Hello "{0}" {1} {2} {3} {4}', null, true, -3.14, NaN, Infinity)`, `Hello "" true -3.14 NaN Infinity`, nil, "format-with-primitives"},
|
||||||
|
{`format('Hello "{0}" {1} {2}', fromJSON('[0, true, "abc"]'), fromJSON('[{"a":1}]'), fromJSON('{"a":{"b":1}}'))`, `Hello "Array" Array Object`, nil, "format-with-complex-types"},
|
||||||
|
{"format(true)", "true", nil, "format-with-primitive-args"},
|
||||||
|
{"format('{0}', github)", "Object", nil, "format-with-context"},
|
||||||
|
{"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", nil, "format-with-undefined-value"},
|
||||||
|
{"format('{0}}', '{1}', 'World')", nil, "Closing bracket without opening one. The following format string is invalid: '{0}}'", "format-invalid-format-string"},
|
||||||
|
{"format('a}b')", nil, "Closing bracket without opening one. The following format string is invalid: 'a}b'", "format-unmatched-closing-brace"},
|
||||||
|
{"format('{0', '{1}', 'World')", nil, "Unclosed brackets. The following format string is invalid: '{0'", "format-invalid-format-string"},
|
||||||
|
{"format('{2}', '{1}', 'World')", "", "The following format string references more arguments than were supplied: '{2}'", "format-invalid-replacement-reference"},
|
||||||
|
{"format('{2147483648}')", "", "The following format string is invalid: '{2147483648}'", "format-invalid-replacement-reference"},
|
||||||
|
{"format('{0} {1} {2} {3}', 1.0, 1.1, 1234567890.0, 12345678901234567890.0)", "1 1.1 1234567890 1.23456789012346E+19", nil, "format-floats"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
if tt.error != nil {
|
||||||
|
assert.Equal(t, tt.error, err.Error())
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusFunctionsNilJob(t *testing.T) {
|
||||||
|
// A nil Job context must not panic: the status-check functions should treat
|
||||||
|
// it as an empty status and return false rather than dereferencing nil.
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
context string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"cancelled()", "job", "cancelled-nil-job"},
|
||||||
|
{"success()", "step", "step-success-nil-job"},
|
||||||
|
{"failure()", "step", "step-failure-nil-job"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{Context: tt.context}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, false, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package exprparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/rhysd/actionlint"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EvaluationEnvironment struct {
|
||||||
|
Github *model.GithubContext
|
||||||
|
Env map[string]string
|
||||||
|
Job *model.JobContext
|
||||||
|
Jobs *map[string]*model.WorkflowCallResult
|
||||||
|
Steps map[string]*model.StepResult
|
||||||
|
Runner map[string]any
|
||||||
|
Secrets map[string]string
|
||||||
|
Vars map[string]string
|
||||||
|
Strategy map[string]any
|
||||||
|
Matrix map[string]any
|
||||||
|
Needs map[string]Needs
|
||||||
|
Inputs map[string]any
|
||||||
|
HashFiles func([]reflect.Value) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Needs struct {
|
||||||
|
Outputs map[string]string `json:"outputs"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Run *model.Run
|
||||||
|
WorkingDir string
|
||||||
|
Context string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultStatusCheck int
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultStatusCheckNone DefaultStatusCheck = iota
|
||||||
|
DefaultStatusCheckSuccess
|
||||||
|
DefaultStatusCheckAlways
|
||||||
|
DefaultStatusCheckCanceled
|
||||||
|
DefaultStatusCheckFailure
|
||||||
|
)
|
||||||
|
|
||||||
|
func (dsc DefaultStatusCheck) String() string {
|
||||||
|
switch dsc {
|
||||||
|
case DefaultStatusCheckSuccess:
|
||||||
|
return "success"
|
||||||
|
case DefaultStatusCheckAlways:
|
||||||
|
return "always"
|
||||||
|
case DefaultStatusCheckCanceled:
|
||||||
|
return "cancelled"
|
||||||
|
case DefaultStatusCheckFailure:
|
||||||
|
return "failure"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type Interpreter interface {
|
||||||
|
Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type interperterImpl struct {
|
||||||
|
env *EvaluationEnvironment
|
||||||
|
config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
|
||||||
|
return &interperterImpl{
|
||||||
|
env: env,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate evaluates one expression. An empty input asks defaultStatusCheck on its own, which is
|
||||||
|
// what a value that carries no expression of its own runs under.
|
||||||
|
func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (any, error) {
|
||||||
|
input = strings.TrimPrefix(input, "${{")
|
||||||
|
|
||||||
|
if input == "" && defaultStatusCheck != DefaultStatusCheckNone {
|
||||||
|
return impl.evaluateNode(statusCheckNode(defaultStatusCheck))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && !CallsStatusFunction(exprNode) {
|
||||||
|
exprNode = &actionlint.LogicalOpNode{
|
||||||
|
Kind: actionlint.LogicalOpNodeKindAnd,
|
||||||
|
Left: statusCheckNode(defaultStatusCheck),
|
||||||
|
Right: exprNode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err2 := impl.evaluateNode(exprNode)
|
||||||
|
|
||||||
|
return result, err2
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusCheckNode(defaultStatusCheck DefaultStatusCheck) *actionlint.FuncCallNode {
|
||||||
|
return &actionlint.FuncCallNode{Callee: defaultStatusCheck.String(), Args: []actionlint.ExprNode{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallsStatusFunction reports whether the expression calls a status function, which counts as the
|
||||||
|
// expression asking its own status question instead of the default one.
|
||||||
|
func CallsStatusFunction(exprNode actionlint.ExprNode) bool {
|
||||||
|
found := 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":
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, error) {
|
||||||
|
switch node := exprNode.(type) {
|
||||||
|
case *actionlint.VariableNode:
|
||||||
|
return impl.evaluateVariable(node)
|
||||||
|
case *actionlint.BoolNode:
|
||||||
|
return node.Value, nil
|
||||||
|
case *actionlint.NullNode:
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
case *actionlint.IntNode:
|
||||||
|
return node.Value, nil
|
||||||
|
case *actionlint.FloatNode:
|
||||||
|
return node.Value, nil
|
||||||
|
case *actionlint.StringNode:
|
||||||
|
return node.Value, nil
|
||||||
|
case *actionlint.IndexAccessNode:
|
||||||
|
return impl.evaluateIndexAccess(node)
|
||||||
|
case *actionlint.ObjectDerefNode:
|
||||||
|
return impl.evaluateObjectDeref(node)
|
||||||
|
case *actionlint.ArrayDerefNode:
|
||||||
|
return impl.evaluateArrayDeref(node)
|
||||||
|
case *actionlint.NotOpNode:
|
||||||
|
return impl.evaluateNot(node)
|
||||||
|
case *actionlint.CompareOpNode:
|
||||||
|
return impl.evaluateCompare(node)
|
||||||
|
case *actionlint.LogicalOpNode:
|
||||||
|
return impl.evaluateLogicalCompare(node)
|
||||||
|
case *actionlint.FuncCallNode:
|
||||||
|
return impl.evaluateFuncCall(node)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Fatal error! Unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
|
||||||
|
switch strings.ToLower(variableNode.Name) {
|
||||||
|
case "github":
|
||||||
|
return impl.env.Github, nil
|
||||||
|
case "gitea": // compatible with Gitea
|
||||||
|
return impl.env.Github, nil
|
||||||
|
case "env":
|
||||||
|
return impl.env.Env, nil
|
||||||
|
case "job":
|
||||||
|
return impl.env.Job, nil
|
||||||
|
case "jobs":
|
||||||
|
if impl.env.Jobs == nil {
|
||||||
|
return nil, errors.New("Unavailable context: jobs")
|
||||||
|
}
|
||||||
|
return impl.env.Jobs, nil
|
||||||
|
case "steps":
|
||||||
|
return impl.env.Steps, nil
|
||||||
|
case "runner":
|
||||||
|
return impl.env.Runner, nil
|
||||||
|
case "secrets":
|
||||||
|
return impl.env.Secrets, nil
|
||||||
|
case "vars":
|
||||||
|
return impl.env.Vars, nil
|
||||||
|
case "strategy":
|
||||||
|
return impl.env.Strategy, nil
|
||||||
|
case "matrix":
|
||||||
|
return impl.env.Matrix, nil
|
||||||
|
case "needs":
|
||||||
|
return impl.env.Needs, nil
|
||||||
|
case "inputs":
|
||||||
|
return impl.env.Inputs, nil
|
||||||
|
case "infinity":
|
||||||
|
return math.Inf(1), nil
|
||||||
|
case "nan":
|
||||||
|
return math.NaN(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Unavailable context: %s", variableNode.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(indexAccessNode.Operand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
leftValue := reflect.ValueOf(left)
|
||||||
|
|
||||||
|
right, err := impl.evaluateNode(indexAccessNode.Index)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rightValue := reflect.ValueOf(right)
|
||||||
|
|
||||||
|
switch rightValue.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
return impl.getPropertyValue(leftValue, rightValue.String())
|
||||||
|
|
||||||
|
case reflect.Int:
|
||||||
|
switch leftValue.Kind() {
|
||||||
|
case reflect.Slice:
|
||||||
|
if rightValue.Int() < 0 || rightValue.Int() >= int64(leftValue.Len()) {
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
return leftValue.Index(int(rightValue.Int())).Interface(), nil
|
||||||
|
default:
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateObjectDeref(objectDerefNode *actionlint.ObjectDerefNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(objectDerefNode.Receiver)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return impl.getPropertyValue(reflect.ValueOf(left), objectDerefNode.Property)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.ArrayDerefNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(arrayDerefNode.Receiver)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return impl.getSafeValue(reflect.ValueOf(left)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
|
||||||
|
switch left.Kind() {
|
||||||
|
case reflect.Pointer:
|
||||||
|
return impl.getPropertyValue(left.Elem(), property)
|
||||||
|
|
||||||
|
case reflect.Struct:
|
||||||
|
leftType := left.Type()
|
||||||
|
for field := range leftType.Fields() {
|
||||||
|
jsonName := field.Tag.Get("json")
|
||||||
|
if jsonName == property {
|
||||||
|
property = field.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldValue := left.FieldByNameFunc(func(name string) bool {
|
||||||
|
return strings.EqualFold(name, property)
|
||||||
|
})
|
||||||
|
|
||||||
|
if fieldValue.Kind() == reflect.Invalid {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
i := fieldValue.Interface()
|
||||||
|
// The type stepStatus int is an integer, but should be treated as string
|
||||||
|
if m, ok := i.(encoding.TextMarshaler); ok {
|
||||||
|
text, err := m.MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return string(text), nil
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
|
||||||
|
case reflect.Map:
|
||||||
|
iter := left.MapRange()
|
||||||
|
|
||||||
|
for iter.Next() {
|
||||||
|
key := iter.Key()
|
||||||
|
|
||||||
|
switch key.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
if strings.EqualFold(key.String(), property) {
|
||||||
|
return impl.getMapValue(iter.Value())
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("'%s' in map key not implemented", key.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
case reflect.Slice:
|
||||||
|
var values []any
|
||||||
|
|
||||||
|
for i := 0; i < left.Len(); i++ {
|
||||||
|
value, err := impl.getPropertyValue(left.Index(i).Elem(), property)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
|
||||||
|
if value.Kind() == reflect.Pointer {
|
||||||
|
return impl.getMapValue(value.Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Interface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateNot(notNode *actionlint.NotOpNode) (any, error) {
|
||||||
|
operand, err := impl.evaluateNode(notNode.Operand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return !IsTruthy(operand), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateCompare(compareNode *actionlint.CompareOpNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(compareNode.Left)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
right, err := impl.evaluateNode(compareNode.Right)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
leftValue := reflect.ValueOf(left)
|
||||||
|
rightValue := reflect.ValueOf(right)
|
||||||
|
|
||||||
|
return impl.compareValues(leftValue, rightValue, compareNode.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) compareValues(leftValue, rightValue reflect.Value, kind actionlint.CompareOpNodeKind) (any, error) {
|
||||||
|
if leftValue.Kind() != rightValue.Kind() {
|
||||||
|
if !impl.isNumber(leftValue) {
|
||||||
|
leftValue = impl.coerceToNumber(leftValue)
|
||||||
|
}
|
||||||
|
if !impl.isNumber(rightValue) {
|
||||||
|
rightValue = impl.coerceToNumber(rightValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch leftValue.Kind() {
|
||||||
|
case reflect.Bool:
|
||||||
|
return impl.compareNumber(float64(impl.coerceToNumber(leftValue).Int()), float64(impl.coerceToNumber(rightValue).Int()), kind)
|
||||||
|
case reflect.String:
|
||||||
|
return impl.compareString(strings.ToLower(leftValue.String()), strings.ToLower(rightValue.String()), kind)
|
||||||
|
|
||||||
|
case reflect.Int:
|
||||||
|
if rightValue.Kind() == reflect.Float64 {
|
||||||
|
return impl.compareNumber(float64(leftValue.Int()), rightValue.Float(), kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
return impl.compareNumber(float64(leftValue.Int()), float64(rightValue.Int()), kind)
|
||||||
|
|
||||||
|
case reflect.Float64:
|
||||||
|
if rightValue.Kind() == reflect.Int {
|
||||||
|
return impl.compareNumber(leftValue.Float(), float64(rightValue.Int()), kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
return impl.compareNumber(leftValue.Float(), rightValue.Float(), kind)
|
||||||
|
|
||||||
|
case reflect.Invalid:
|
||||||
|
if rightValue.Kind() == reflect.Invalid {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// not possible situation - params are converted to the same type in code above
|
||||||
|
return nil, fmt.Errorf("Compare params of Invalid type: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Compare not implemented for types: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) coerceToNumber(value reflect.Value) reflect.Value {
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Invalid:
|
||||||
|
return reflect.ValueOf(0)
|
||||||
|
|
||||||
|
case reflect.Bool:
|
||||||
|
switch value.Bool() {
|
||||||
|
case true:
|
||||||
|
return reflect.ValueOf(1)
|
||||||
|
case false:
|
||||||
|
return reflect.ValueOf(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
case reflect.String:
|
||||||
|
if value.String() == "" {
|
||||||
|
return reflect.ValueOf(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to parse the string as a number
|
||||||
|
evaluated, err := impl.Evaluate(value.String(), DefaultStatusCheckNone)
|
||||||
|
if err != nil {
|
||||||
|
return reflect.ValueOf(math.NaN())
|
||||||
|
}
|
||||||
|
|
||||||
|
if value := reflect.ValueOf(evaluated); impl.isNumber(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflect.ValueOf(math.NaN())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoerceToString converts an evaluated expression value to a string the way GitHub does,
|
||||||
|
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
|
||||||
|
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
|
||||||
|
func CoerceToString(v any) string {
|
||||||
|
value, ok := v.(reflect.Value)
|
||||||
|
if !ok {
|
||||||
|
value = reflect.ValueOf(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Invalid:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
case reflect.Bool:
|
||||||
|
return strconv.FormatBool(value.Bool())
|
||||||
|
|
||||||
|
case reflect.String:
|
||||||
|
return value.String()
|
||||||
|
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return strconv.FormatInt(value.Int(), 10)
|
||||||
|
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
return strconv.FormatUint(value.Uint(), 10)
|
||||||
|
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
if math.IsInf(value.Float(), 1) {
|
||||||
|
return "Infinity"
|
||||||
|
} else if math.IsInf(value.Float(), -1) {
|
||||||
|
return "-Infinity"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.15G", value.Float())
|
||||||
|
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
return "Array"
|
||||||
|
|
||||||
|
// contexts such as `github` are pointers to structs, so they stringify as objects too
|
||||||
|
case reflect.Map, reflect.Struct:
|
||||||
|
return "Object"
|
||||||
|
|
||||||
|
case reflect.Interface, reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return CoerceToString(value.Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) compareString(left, right string, kind actionlint.CompareOpNodeKind) (bool, error) {
|
||||||
|
switch kind {
|
||||||
|
case actionlint.CompareOpNodeKindLess:
|
||||||
|
return left < right, nil
|
||||||
|
case actionlint.CompareOpNodeKindLessEq:
|
||||||
|
return left <= right, nil
|
||||||
|
case actionlint.CompareOpNodeKindGreater:
|
||||||
|
return left > right, nil
|
||||||
|
case actionlint.CompareOpNodeKindGreaterEq:
|
||||||
|
return left >= right, nil
|
||||||
|
case actionlint.CompareOpNodeKindEq:
|
||||||
|
return left == right, nil
|
||||||
|
case actionlint.CompareOpNodeKindNotEq:
|
||||||
|
return left != right, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) compareNumber(left, right float64, kind actionlint.CompareOpNodeKind) (bool, error) {
|
||||||
|
switch kind {
|
||||||
|
case actionlint.CompareOpNodeKindLess:
|
||||||
|
return left < right, nil
|
||||||
|
case actionlint.CompareOpNodeKindLessEq:
|
||||||
|
return left <= right, nil
|
||||||
|
case actionlint.CompareOpNodeKindGreater:
|
||||||
|
return left > right, nil
|
||||||
|
case actionlint.CompareOpNodeKindGreaterEq:
|
||||||
|
return left >= right, nil
|
||||||
|
case actionlint.CompareOpNodeKindEq:
|
||||||
|
return left == right, nil
|
||||||
|
case actionlint.CompareOpNodeKindNotEq:
|
||||||
|
return left != right, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsTruthy(input any) bool {
|
||||||
|
value := reflect.ValueOf(input)
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Bool:
|
||||||
|
return value.Bool()
|
||||||
|
|
||||||
|
case reflect.String:
|
||||||
|
return value.String() != ""
|
||||||
|
|
||||||
|
case reflect.Int:
|
||||||
|
return value.Int() != 0
|
||||||
|
|
||||||
|
case reflect.Float64:
|
||||||
|
if math.IsNaN(value.Float()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Float() != 0
|
||||||
|
|
||||||
|
case reflect.Map, reflect.Slice:
|
||||||
|
return true
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) isNumber(value reflect.Value) bool {
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Int, reflect.Float64:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) getSafeValue(value reflect.Value) any {
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Invalid:
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case reflect.Float64:
|
||||||
|
if value.Float() == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Interface()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.LogicalOpNode) (any, error) {
|
||||||
|
left, err := impl.evaluateNode(compareNode.Left)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
leftValue := reflect.ValueOf(left)
|
||||||
|
|
||||||
|
if IsTruthy(left) == (compareNode.Kind == actionlint.LogicalOpNodeKindOr) {
|
||||||
|
return impl.getSafeValue(leftValue), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
right, err := impl.evaluateNode(compareNode.Right)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rightValue := reflect.ValueOf(right)
|
||||||
|
|
||||||
|
switch compareNode.Kind {
|
||||||
|
case actionlint.LogicalOpNodeKindAnd:
|
||||||
|
return impl.getSafeValue(rightValue), nil
|
||||||
|
case actionlint.LogicalOpNodeKindOr:
|
||||||
|
return impl.getSafeValue(rightValue), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
|
||||||
|
args := make([]reflect.Value, 0)
|
||||||
|
|
||||||
|
for _, arg := range funcCallNode.Args {
|
||||||
|
value, err := impl.evaluateNode(arg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, reflect.ValueOf(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(funcCallNode.Callee) {
|
||||||
|
case "contains":
|
||||||
|
return impl.contains(args[0], args[1])
|
||||||
|
case "startswith":
|
||||||
|
return impl.startsWith(args[0], args[1])
|
||||||
|
case "endswith":
|
||||||
|
return impl.endsWith(args[0], args[1])
|
||||||
|
case "format":
|
||||||
|
return impl.format(args[0], args[1:]...)
|
||||||
|
case "join":
|
||||||
|
if len(args) == 1 {
|
||||||
|
return impl.join(args[0], reflect.ValueOf(","))
|
||||||
|
}
|
||||||
|
return impl.join(args[0], args[1])
|
||||||
|
case "tojson":
|
||||||
|
return impl.toJSON(args[0])
|
||||||
|
case "fromjson":
|
||||||
|
return impl.fromJSON(args[0])
|
||||||
|
case "hashfiles":
|
||||||
|
if impl.env.HashFiles != nil {
|
||||||
|
return impl.env.HashFiles(args)
|
||||||
|
}
|
||||||
|
return impl.hashFiles(args...)
|
||||||
|
case "always":
|
||||||
|
return impl.always()
|
||||||
|
case "success":
|
||||||
|
if impl.config.Context == "job" {
|
||||||
|
return impl.jobSuccess()
|
||||||
|
}
|
||||||
|
if impl.config.Context == "step" {
|
||||||
|
return impl.stepSuccess()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
||||||
|
case "failure":
|
||||||
|
if impl.config.Context == "job" {
|
||||||
|
return impl.jobFailure()
|
||||||
|
}
|
||||||
|
if impl.config.Context == "step" {
|
||||||
|
return impl.stepFailure()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
|
||||||
|
case "cancelled":
|
||||||
|
return impl.cancelled()
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("TODO: '%s' not implemented", funcCallNode.Callee)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package exprparser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLiterals(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"true", true, "true"},
|
||||||
|
{"false", false, "false"},
|
||||||
|
{"null", nil, "null"},
|
||||||
|
{"123", 123, "integer"},
|
||||||
|
{"-9.7", -9.7, "float"},
|
||||||
|
{"0xff", 255, "hex"},
|
||||||
|
{"-2.99e-2", -2.99e-2, "exponential"},
|
||||||
|
{"'foo'", "foo", "string"},
|
||||||
|
{"'it''s foo'", "it's foo", "string"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperators(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
error string
|
||||||
|
}{
|
||||||
|
{"(false || (false || true))", true, "logical-grouping", ""},
|
||||||
|
{"github.action", "push", "property-dereference", ""},
|
||||||
|
{"github['action']", "push", "property-index", ""},
|
||||||
|
{"github.action[0]", nil, "string-index", ""},
|
||||||
|
{"github.action['0']", nil, "string-index", ""},
|
||||||
|
{"fromJSON('[0,1]')[1]", 1.0, "array-index", ""},
|
||||||
|
{"fromJSON('[0,1]')[1.1]", nil, "array-index", ""},
|
||||||
|
// Disabled weird things are happening
|
||||||
|
// {"fromJSON('[0,1]')['1.1']", nil, "array-index", ""},
|
||||||
|
{"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""},
|
||||||
|
{"fromJSON('[0,1]')[2]", nil, "array-index-out-of-bounds-0", ""},
|
||||||
|
{"fromJSON('[0,1]')[34553]", nil, "array-index-out-of-bounds-1", ""},
|
||||||
|
{"fromJSON('[0,1]')[-1]", nil, "array-index-out-of-bounds-2", ""},
|
||||||
|
{"fromJSON('[0,1]')[-34553]", nil, "array-index-out-of-bounds-3", ""},
|
||||||
|
{"!true", false, "not", ""},
|
||||||
|
{"1 < 2", true, "less-than", ""},
|
||||||
|
{`'b' <= 'a'`, false, "less-than-or-equal", ""},
|
||||||
|
{"1 > 2", false, "greater-than", ""},
|
||||||
|
{`'b' >= 'a'`, true, "greater-than-or-equal", ""},
|
||||||
|
{`'a' == 'a'`, true, "equal", ""},
|
||||||
|
{`'a' != 'a'`, false, "not-equal", ""},
|
||||||
|
{`true && false`, false, "and", ""},
|
||||||
|
{`true || false`, true, "or", ""},
|
||||||
|
{`fromJSON('{}') && true`, true, "and-boolean-object", ""},
|
||||||
|
{`fromJSON('{}') || false`, make(map[string]any), "or-boolean-object", ""},
|
||||||
|
{"github.event.commits[0].author.username != github.event.commits[1].author.username", true, "property-comparison1", ""},
|
||||||
|
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username", true, "property-comparison2", ""},
|
||||||
|
{"github.event.commits[0].author.username != github.event.commits[1].author.username1", true, "property-comparison3", ""},
|
||||||
|
{"github.event.commits[0].author.username1 != github.event.commits[1].author.username2", true, "property-comparison4", ""},
|
||||||
|
{"secrets != env", nil, "property-comparison5", "Compare not implemented for types: left: map, right: map"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{
|
||||||
|
Action: "push",
|
||||||
|
Event: map[string]any{
|
||||||
|
"commits": []any{
|
||||||
|
map[string]any{
|
||||||
|
"author": map[string]any{
|
||||||
|
"username": "someone",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"author": map[string]any{
|
||||||
|
"username": "someone-else",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
if tt.error != "" {
|
||||||
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, tt.error, err.Error())
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperatorsCompare(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"!null", true, "not-null"},
|
||||||
|
{"!-10", false, "not-neg-num"},
|
||||||
|
{"!0", true, "not-zero"},
|
||||||
|
{"!3.14", false, "not-pos-float"},
|
||||||
|
{"!''", true, "not-empty-str"},
|
||||||
|
{"!'abc'", false, "not-str"},
|
||||||
|
{"!fromJSON('{}')", false, "not-obj"},
|
||||||
|
{"!fromJSON('[]')", false, "not-arr"},
|
||||||
|
{`null == 0 }}`, true, "null-coercion"},
|
||||||
|
{`true == 1 }}`, true, "boolean-coercion"},
|
||||||
|
{`'' == 0 }}`, true, "string-0-coercion"},
|
||||||
|
{`'3' == 3 }}`, true, "string-3-coercion"},
|
||||||
|
{`0 == null }}`, true, "null-coercion-alt"},
|
||||||
|
{`1 == true }}`, true, "boolean-coercion-alt"},
|
||||||
|
{`0 == '' }}`, true, "string-0-coercion-alt"},
|
||||||
|
{`3 == '3' }}`, true, "string-3-coercion-alt"},
|
||||||
|
{`'TEST' == 'test' }}`, true, "string-casing"},
|
||||||
|
{"true > false }}", true, "bool-greater-than"},
|
||||||
|
{"true >= false }}", true, "bool-greater-than-eq"},
|
||||||
|
{"true >= true }}", true, "bool-greater-than-1"},
|
||||||
|
{"true != false }}", true, "bool-not-equal"},
|
||||||
|
{`fromJSON('{}') < 2 }}`, false, "object-with-less"},
|
||||||
|
{`fromJSON('{}') < fromJSON('[]') }}`, false, "object/arr-with-lt"},
|
||||||
|
{`fromJSON('{}') > fromJSON('[]') }}`, false, "object/arr-with-gt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{
|
||||||
|
Action: "push",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOperatorsBooleanEvaluation(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
// true &&
|
||||||
|
{"true && true", true, "true-and"},
|
||||||
|
{"true && false", false, "true-and"},
|
||||||
|
{"true && null", nil, "true-and"},
|
||||||
|
{"true && -10", -10, "true-and"},
|
||||||
|
{"true && 0", 0, "true-and"},
|
||||||
|
{"true && 10", 10, "true-and"},
|
||||||
|
{"true && 3.14", 3.14, "true-and"},
|
||||||
|
{"true && 0.0", 0, "true-and"},
|
||||||
|
{"true && Infinity", math.Inf(1), "true-and"},
|
||||||
|
// {"true && -Infinity", math.Inf(-1), "true-and"},
|
||||||
|
{"true && NaN", math.NaN(), "true-and"},
|
||||||
|
{"true && ''", "", "true-and"},
|
||||||
|
{"true && 'abc'", "abc", "true-and"},
|
||||||
|
// false &&
|
||||||
|
{"false && true", false, "false-and"},
|
||||||
|
{"false && false", false, "false-and"},
|
||||||
|
{"false && null", false, "false-and"},
|
||||||
|
{"false && -10", false, "false-and"},
|
||||||
|
{"false && 0", false, "false-and"},
|
||||||
|
{"false && 10", false, "false-and"},
|
||||||
|
{"false && 3.14", false, "false-and"},
|
||||||
|
{"false && 0.0", false, "false-and"},
|
||||||
|
{"false && Infinity", false, "false-and"},
|
||||||
|
// {"false && -Infinity", false, "false-and"},
|
||||||
|
{"false && NaN", false, "false-and"},
|
||||||
|
{"false && ''", false, "false-and"},
|
||||||
|
{"false && 'abc'", false, "false-and"},
|
||||||
|
// true ||
|
||||||
|
{"true || true", true, "true-or"},
|
||||||
|
{"true || false", true, "true-or"},
|
||||||
|
{"true || null", true, "true-or"},
|
||||||
|
{"true || -10", true, "true-or"},
|
||||||
|
{"true || 0", true, "true-or"},
|
||||||
|
{"true || 10", true, "true-or"},
|
||||||
|
{"true || 3.14", true, "true-or"},
|
||||||
|
{"true || 0.0", true, "true-or"},
|
||||||
|
{"true || Infinity", true, "true-or"},
|
||||||
|
// {"true || -Infinity", true, "true-or"},
|
||||||
|
{"true || NaN", true, "true-or"},
|
||||||
|
{"true || ''", true, "true-or"},
|
||||||
|
{"true || 'abc'", true, "true-or"},
|
||||||
|
// false ||
|
||||||
|
{"false || true", true, "false-or"},
|
||||||
|
{"false || false", false, "false-or"},
|
||||||
|
{"false || null", nil, "false-or"},
|
||||||
|
{"false || -10", -10, "false-or"},
|
||||||
|
{"false || 0", 0, "false-or"},
|
||||||
|
{"false || 10", 10, "false-or"},
|
||||||
|
{"false || 3.14", 3.14, "false-or"},
|
||||||
|
{"false || 0.0", 0, "false-or"},
|
||||||
|
{"false || Infinity", math.Inf(1), "false-or"},
|
||||||
|
// {"false || -Infinity", math.Inf(-1), "false-or"},
|
||||||
|
{"false || NaN", math.NaN(), "false-or"},
|
||||||
|
{"false || ''", "", "false-or"},
|
||||||
|
{"false || 'abc'", "abc", "false-or"},
|
||||||
|
// null &&
|
||||||
|
{"null && true", nil, "null-and"},
|
||||||
|
{"null && false", nil, "null-and"},
|
||||||
|
{"null && null", nil, "null-and"},
|
||||||
|
{"null && -10", nil, "null-and"},
|
||||||
|
{"null && 0", nil, "null-and"},
|
||||||
|
{"null && 10", nil, "null-and"},
|
||||||
|
{"null && 3.14", nil, "null-and"},
|
||||||
|
{"null && 0.0", nil, "null-and"},
|
||||||
|
{"null && Infinity", nil, "null-and"},
|
||||||
|
// {"null && -Infinity", nil, "null-and"},
|
||||||
|
{"null && NaN", nil, "null-and"},
|
||||||
|
{"null && ''", nil, "null-and"},
|
||||||
|
{"null && 'abc'", nil, "null-and"},
|
||||||
|
// null ||
|
||||||
|
{"null || true", true, "null-or"},
|
||||||
|
{"null || false", false, "null-or"},
|
||||||
|
{"null || null", nil, "null-or"},
|
||||||
|
{"null || -10", -10, "null-or"},
|
||||||
|
{"null || 0", 0, "null-or"},
|
||||||
|
{"null || 10", 10, "null-or"},
|
||||||
|
{"null || 3.14", 3.14, "null-or"},
|
||||||
|
{"null || 0.0", 0, "null-or"},
|
||||||
|
{"null || Infinity", math.Inf(1), "null-or"},
|
||||||
|
// {"null || -Infinity", math.Inf(-1), "null-or"},
|
||||||
|
{"null || NaN", math.NaN(), "null-or"},
|
||||||
|
{"null || ''", "", "null-or"},
|
||||||
|
{"null || 'abc'", "abc", "null-or"},
|
||||||
|
// -10 &&
|
||||||
|
{"-10 && true", true, "neg-num-and"},
|
||||||
|
{"-10 && false", false, "neg-num-and"},
|
||||||
|
{"-10 && null", nil, "neg-num-and"},
|
||||||
|
{"-10 && -10", -10, "neg-num-and"},
|
||||||
|
{"-10 && 0", 0, "neg-num-and"},
|
||||||
|
{"-10 && 10", 10, "neg-num-and"},
|
||||||
|
{"-10 && 3.14", 3.14, "neg-num-and"},
|
||||||
|
{"-10 && 0.0", 0, "neg-num-and"},
|
||||||
|
{"-10 && Infinity", math.Inf(1), "neg-num-and"},
|
||||||
|
// {"-10 && -Infinity", math.Inf(-1), "neg-num-and"},
|
||||||
|
{"-10 && NaN", math.NaN(), "neg-num-and"},
|
||||||
|
{"-10 && ''", "", "neg-num-and"},
|
||||||
|
{"-10 && 'abc'", "abc", "neg-num-and"},
|
||||||
|
// -10 ||
|
||||||
|
{"-10 || true", -10, "neg-num-or"},
|
||||||
|
{"-10 || false", -10, "neg-num-or"},
|
||||||
|
{"-10 || null", -10, "neg-num-or"},
|
||||||
|
{"-10 || -10", -10, "neg-num-or"},
|
||||||
|
{"-10 || 0", -10, "neg-num-or"},
|
||||||
|
{"-10 || 10", -10, "neg-num-or"},
|
||||||
|
{"-10 || 3.14", -10, "neg-num-or"},
|
||||||
|
{"-10 || 0.0", -10, "neg-num-or"},
|
||||||
|
{"-10 || Infinity", -10, "neg-num-or"},
|
||||||
|
// {"-10 || -Infinity", -10, "neg-num-or"},
|
||||||
|
{"-10 || NaN", -10, "neg-num-or"},
|
||||||
|
{"-10 || ''", -10, "neg-num-or"},
|
||||||
|
{"-10 || 'abc'", -10, "neg-num-or"},
|
||||||
|
// 0 &&
|
||||||
|
{"0 && true", 0, "zero-and"},
|
||||||
|
{"0 && false", 0, "zero-and"},
|
||||||
|
{"0 && null", 0, "zero-and"},
|
||||||
|
{"0 && -10", 0, "zero-and"},
|
||||||
|
{"0 && 0", 0, "zero-and"},
|
||||||
|
{"0 && 10", 0, "zero-and"},
|
||||||
|
{"0 && 3.14", 0, "zero-and"},
|
||||||
|
{"0 && 0.0", 0, "zero-and"},
|
||||||
|
{"0 && Infinity", 0, "zero-and"},
|
||||||
|
// {"0 && -Infinity", 0, "zero-and"},
|
||||||
|
{"0 && NaN", 0, "zero-and"},
|
||||||
|
{"0 && ''", 0, "zero-and"},
|
||||||
|
{"0 && 'abc'", 0, "zero-and"},
|
||||||
|
// 0 ||
|
||||||
|
{"0 || true", true, "zero-or"},
|
||||||
|
{"0 || false", false, "zero-or"},
|
||||||
|
{"0 || null", nil, "zero-or"},
|
||||||
|
{"0 || -10", -10, "zero-or"},
|
||||||
|
{"0 || 0", 0, "zero-or"},
|
||||||
|
{"0 || 10", 10, "zero-or"},
|
||||||
|
{"0 || 3.14", 3.14, "zero-or"},
|
||||||
|
{"0 || 0.0", 0, "zero-or"},
|
||||||
|
{"0 || Infinity", math.Inf(1), "zero-or"},
|
||||||
|
// {"0 || -Infinity", math.Inf(-1), "zero-or"},
|
||||||
|
{"0 || NaN", math.NaN(), "zero-or"},
|
||||||
|
{"0 || ''", "", "zero-or"},
|
||||||
|
{"0 || 'abc'", "abc", "zero-or"},
|
||||||
|
// 10 &&
|
||||||
|
{"10 && true", true, "pos-num-and"},
|
||||||
|
{"10 && false", false, "pos-num-and"},
|
||||||
|
{"10 && null", nil, "pos-num-and"},
|
||||||
|
{"10 && -10", -10, "pos-num-and"},
|
||||||
|
{"10 && 0", 0, "pos-num-and"},
|
||||||
|
{"10 && 10", 10, "pos-num-and"},
|
||||||
|
{"10 && 3.14", 3.14, "pos-num-and"},
|
||||||
|
{"10 && 0.0", 0, "pos-num-and"},
|
||||||
|
{"10 && Infinity", math.Inf(1), "pos-num-and"},
|
||||||
|
// {"10 && -Infinity", math.Inf(-1), "pos-num-and"},
|
||||||
|
{"10 && NaN", math.NaN(), "pos-num-and"},
|
||||||
|
{"10 && ''", "", "pos-num-and"},
|
||||||
|
{"10 && 'abc'", "abc", "pos-num-and"},
|
||||||
|
// 10 ||
|
||||||
|
{"10 || true", 10, "pos-num-or"},
|
||||||
|
{"10 || false", 10, "pos-num-or"},
|
||||||
|
{"10 || null", 10, "pos-num-or"},
|
||||||
|
{"10 || -10", 10, "pos-num-or"},
|
||||||
|
{"10 || 0", 10, "pos-num-or"},
|
||||||
|
{"10 || 10", 10, "pos-num-or"},
|
||||||
|
{"10 || 3.14", 10, "pos-num-or"},
|
||||||
|
{"10 || 0.0", 10, "pos-num-or"},
|
||||||
|
{"10 || Infinity", 10, "pos-num-or"},
|
||||||
|
// {"10 || -Infinity", 10, "pos-num-or"},
|
||||||
|
{"10 || NaN", 10, "pos-num-or"},
|
||||||
|
{"10 || ''", 10, "pos-num-or"},
|
||||||
|
{"10 || 'abc'", 10, "pos-num-or"},
|
||||||
|
// 3.14 &&
|
||||||
|
{"3.14 && true", true, "pos-float-and"},
|
||||||
|
{"3.14 && false", false, "pos-float-and"},
|
||||||
|
{"3.14 && null", nil, "pos-float-and"},
|
||||||
|
{"3.14 && -10", -10, "pos-float-and"},
|
||||||
|
{"3.14 && 0", 0, "pos-float-and"},
|
||||||
|
{"3.14 && 10", 10, "pos-float-and"},
|
||||||
|
{"3.14 && 3.14", 3.14, "pos-float-and"},
|
||||||
|
{"3.14 && 0.0", 0, "pos-float-and"},
|
||||||
|
{"3.14 && Infinity", math.Inf(1), "pos-float-and"},
|
||||||
|
// {"3.14 && -Infinity", math.Inf(-1), "pos-float-and"},
|
||||||
|
{"3.14 && NaN", math.NaN(), "pos-float-and"},
|
||||||
|
{"3.14 && ''", "", "pos-float-and"},
|
||||||
|
{"3.14 && 'abc'", "abc", "pos-float-and"},
|
||||||
|
// 3.14 ||
|
||||||
|
{"3.14 || true", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || false", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || null", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || -10", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 0", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 10", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 3.14", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 0.0", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || Infinity", 3.14, "pos-float-or"},
|
||||||
|
// {"3.14 || -Infinity", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || NaN", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || ''", 3.14, "pos-float-or"},
|
||||||
|
{"3.14 || 'abc'", 3.14, "pos-float-or"},
|
||||||
|
// Infinity &&
|
||||||
|
{"Infinity && true", true, "pos-inf-and"},
|
||||||
|
{"Infinity && false", false, "pos-inf-and"},
|
||||||
|
{"Infinity && null", nil, "pos-inf-and"},
|
||||||
|
{"Infinity && -10", -10, "pos-inf-and"},
|
||||||
|
{"Infinity && 0", 0, "pos-inf-and"},
|
||||||
|
{"Infinity && 10", 10, "pos-inf-and"},
|
||||||
|
{"Infinity && 3.14", 3.14, "pos-inf-and"},
|
||||||
|
{"Infinity && 0.0", 0, "pos-inf-and"},
|
||||||
|
{"Infinity && Infinity", math.Inf(1), "pos-inf-and"},
|
||||||
|
// {"Infinity && -Infinity", math.Inf(-1), "pos-inf-and"},
|
||||||
|
{"Infinity && NaN", math.NaN(), "pos-inf-and"},
|
||||||
|
{"Infinity && ''", "", "pos-inf-and"},
|
||||||
|
{"Infinity && 'abc'", "abc", "pos-inf-and"},
|
||||||
|
// Infinity ||
|
||||||
|
{"Infinity || true", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || false", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || null", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || -10", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 0", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 10", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 3.14", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 0.0", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || Infinity", math.Inf(1), "pos-inf-or"},
|
||||||
|
// {"Infinity || -Infinity", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || NaN", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || ''", math.Inf(1), "pos-inf-or"},
|
||||||
|
{"Infinity || 'abc'", math.Inf(1), "pos-inf-or"},
|
||||||
|
// -Infinity &&
|
||||||
|
// {"-Infinity && true", true, "neg-inf-and"},
|
||||||
|
// {"-Infinity && false", false, "neg-inf-and"},
|
||||||
|
// {"-Infinity && null", nil, "neg-inf-and"},
|
||||||
|
// {"-Infinity && -10", -10, "neg-inf-and"},
|
||||||
|
// {"-Infinity && 0", 0, "neg-inf-and"},
|
||||||
|
// {"-Infinity && 10", 10, "neg-inf-and"},
|
||||||
|
// {"-Infinity && 3.14", 3.14, "neg-inf-and"},
|
||||||
|
// {"-Infinity && 0.0", 0, "neg-inf-and"},
|
||||||
|
// {"-Infinity && Infinity", math.Inf(1), "neg-inf-and"},
|
||||||
|
// {"-Infinity && -Infinity", math.Inf(-1), "neg-inf-and"},
|
||||||
|
// {"-Infinity && NaN", math.NaN(), "neg-inf-and"},
|
||||||
|
// {"-Infinity && ''", "", "neg-inf-and"},
|
||||||
|
// {"-Infinity && 'abc'", "abc", "neg-inf-and"},
|
||||||
|
// -Infinity ||
|
||||||
|
// {"-Infinity || true", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || false", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || null", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || -10", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 0", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 10", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 3.14", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 0.0", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || Infinity", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || -Infinity", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || NaN", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || ''", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// {"-Infinity || 'abc'", math.Inf(-1), "neg-inf-or"},
|
||||||
|
// NaN &&
|
||||||
|
{"NaN && true", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && false", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && null", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && -10", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 0", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 10", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 3.14", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 0.0", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && Infinity", math.NaN(), "nan-and"},
|
||||||
|
// {"NaN && -Infinity", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && NaN", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && ''", math.NaN(), "nan-and"},
|
||||||
|
{"NaN && 'abc'", math.NaN(), "nan-and"},
|
||||||
|
// NaN ||
|
||||||
|
{"NaN || true", true, "nan-or"},
|
||||||
|
{"NaN || false", false, "nan-or"},
|
||||||
|
{"NaN || null", nil, "nan-or"},
|
||||||
|
{"NaN || -10", -10, "nan-or"},
|
||||||
|
{"NaN || 0", 0, "nan-or"},
|
||||||
|
{"NaN || 10", 10, "nan-or"},
|
||||||
|
{"NaN || 3.14", 3.14, "nan-or"},
|
||||||
|
{"NaN || 0.0", 0, "nan-or"},
|
||||||
|
{"NaN || Infinity", math.Inf(1), "nan-or"},
|
||||||
|
// {"NaN || -Infinity", math.Inf(-1), "nan-or"},
|
||||||
|
{"NaN || NaN", math.NaN(), "nan-or"},
|
||||||
|
{"NaN || ''", "", "nan-or"},
|
||||||
|
{"NaN || 'abc'", "abc", "nan-or"},
|
||||||
|
// "" &&
|
||||||
|
{"'' && true", "", "empty-str-and"},
|
||||||
|
{"'' && false", "", "empty-str-and"},
|
||||||
|
{"'' && null", "", "empty-str-and"},
|
||||||
|
{"'' && -10", "", "empty-str-and"},
|
||||||
|
{"'' && 0", "", "empty-str-and"},
|
||||||
|
{"'' && 10", "", "empty-str-and"},
|
||||||
|
{"'' && 3.14", "", "empty-str-and"},
|
||||||
|
{"'' && 0.0", "", "empty-str-and"},
|
||||||
|
{"'' && Infinity", "", "empty-str-and"},
|
||||||
|
// {"'' && -Infinity", "", "empty-str-and"},
|
||||||
|
{"'' && NaN", "", "empty-str-and"},
|
||||||
|
{"'' && ''", "", "empty-str-and"},
|
||||||
|
{"'' && 'abc'", "", "empty-str-and"},
|
||||||
|
// "" ||
|
||||||
|
{"'' || true", true, "empty-str-or"},
|
||||||
|
{"'' || false", false, "empty-str-or"},
|
||||||
|
{"'' || null", nil, "empty-str-or"},
|
||||||
|
{"'' || -10", -10, "empty-str-or"},
|
||||||
|
{"'' || 0", 0, "empty-str-or"},
|
||||||
|
{"'' || 10", 10, "empty-str-or"},
|
||||||
|
{"'' || 3.14", 3.14, "empty-str-or"},
|
||||||
|
{"'' || 0.0", 0, "empty-str-or"},
|
||||||
|
{"'' || Infinity", math.Inf(1), "empty-str-or"},
|
||||||
|
// {"'' || -Infinity", math.Inf(-1), "empty-str-or"},
|
||||||
|
{"'' || NaN", math.NaN(), "empty-str-or"},
|
||||||
|
{"'' || ''", "", "empty-str-or"},
|
||||||
|
{"'' || 'abc'", "abc", "empty-str-or"},
|
||||||
|
// "abc" &&
|
||||||
|
{"'abc' && true", true, "str-and"},
|
||||||
|
{"'abc' && false", false, "str-and"},
|
||||||
|
{"'abc' && null", nil, "str-and"},
|
||||||
|
{"'abc' && -10", -10, "str-and"},
|
||||||
|
{"'abc' && 0", 0, "str-and"},
|
||||||
|
{"'abc' && 10", 10, "str-and"},
|
||||||
|
{"'abc' && 3.14", 3.14, "str-and"},
|
||||||
|
{"'abc' && 0.0", 0, "str-and"},
|
||||||
|
{"'abc' && Infinity", math.Inf(1), "str-and"},
|
||||||
|
// {"'abc' && -Infinity", math.Inf(-1), "str-and"},
|
||||||
|
{"'abc' && NaN", math.NaN(), "str-and"},
|
||||||
|
{"'abc' && ''", "", "str-and"},
|
||||||
|
{"'abc' && 'abc'", "abc", "str-and"},
|
||||||
|
// "abc" ||
|
||||||
|
{"'abc' || true", "abc", "str-or"},
|
||||||
|
{"'abc' || false", "abc", "str-or"},
|
||||||
|
{"'abc' || null", "abc", "str-or"},
|
||||||
|
{"'abc' || -10", "abc", "str-or"},
|
||||||
|
{"'abc' || 0", "abc", "str-or"},
|
||||||
|
{"'abc' || 10", "abc", "str-or"},
|
||||||
|
{"'abc' || 3.14", "abc", "str-or"},
|
||||||
|
{"'abc' || 0.0", "abc", "str-or"},
|
||||||
|
{"'abc' || Infinity", "abc", "str-or"},
|
||||||
|
// {"'abc' || -Infinity", "abc", "str-or"},
|
||||||
|
{"'abc' || NaN", "abc", "str-or"},
|
||||||
|
{"'abc' || ''", "abc", "str-or"},
|
||||||
|
{"'abc' || 'abc'", "abc", "str-or"},
|
||||||
|
// extra tests
|
||||||
|
{"0.0 && true", 0, "float-evaluation-0-alt"},
|
||||||
|
{"-1.5 && true", true, "float-evaluation-neg-alt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{
|
||||||
|
Action: "push",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
|
||||||
|
number, ok := output.(float64)
|
||||||
|
require.True(t, ok, "want a number, got %T", output)
|
||||||
|
assert.True(t, math.IsNaN(number))
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContexts(t *testing.T) {
|
||||||
|
table := []struct {
|
||||||
|
input string
|
||||||
|
expected any
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"github.action", "push", "github-context"},
|
||||||
|
{"github.event.commits[0].message", nil, "github-context-noexist-prop"},
|
||||||
|
{"fromjson('{\"commits\":[]}').commits[0].message", nil, "github-context-noexist-prop"},
|
||||||
|
{"github.event.pull_request.labels.*.name", nil, "github-context-noexist-prop"},
|
||||||
|
{"env.TEST", "value", "env-context"},
|
||||||
|
{"job.status", "success", "job-context"},
|
||||||
|
{"steps.step-id.outputs.name", "value", "steps-context"},
|
||||||
|
{"steps.step-id.conclusion", "success", "steps-context-conclusion"},
|
||||||
|
{"steps.step-id.conclusion && true", true, "steps-context-conclusion"},
|
||||||
|
{"steps.step-id2.conclusion", "skipped", "steps-context-conclusion"},
|
||||||
|
{"steps.step-id2.conclusion && true", true, "steps-context-conclusion"},
|
||||||
|
{"steps.step-id.outcome", "success", "steps-context-outcome"},
|
||||||
|
{"steps.step-id['outcome']", "success", "steps-context-outcome"},
|
||||||
|
{"steps.step-id.outcome == 'success'", true, "steps-context-outcome"},
|
||||||
|
{"steps.step-id['outcome'] == 'success'", true, "steps-context-outcome"},
|
||||||
|
{"steps.step-id.outcome && true", true, "steps-context-outcome"},
|
||||||
|
{"steps['step-id']['outcome'] && true", true, "steps-context-outcome"},
|
||||||
|
{"steps.step-id2.outcome", "failure", "steps-context-outcome"},
|
||||||
|
{"steps.step-id2.outcome && true", true, "steps-context-outcome"},
|
||||||
|
// Disabled, since the interpreter is still too broken
|
||||||
|
// {"contains(steps.*.outcome, 'success')", true, "steps-context-array-outcome"},
|
||||||
|
// {"contains(steps.*.outcome, 'failure')", true, "steps-context-array-outcome"},
|
||||||
|
// {"contains(steps.*.outputs.name, 'value')", true, "steps-context-array-outputs"},
|
||||||
|
{"runner.os", "Linux", "runner-context"},
|
||||||
|
{"secrets.name", "value", "secrets-context"},
|
||||||
|
{"vars.name", "value", "vars-context"},
|
||||||
|
{"strategy.fail-fast", true, "strategy-context"},
|
||||||
|
{"matrix.os", "Linux", "matrix-context"},
|
||||||
|
{"needs.job-id.outputs.output-name", "value", "needs-context"},
|
||||||
|
{"needs.job-id.result", "success", "needs-context"},
|
||||||
|
{"inputs.name", "value", "inputs-context"},
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &EvaluationEnvironment{
|
||||||
|
Github: &model.GithubContext{
|
||||||
|
Action: "push",
|
||||||
|
},
|
||||||
|
Env: map[string]string{
|
||||||
|
"TEST": "value",
|
||||||
|
},
|
||||||
|
Job: &model.JobContext{
|
||||||
|
Status: "success",
|
||||||
|
},
|
||||||
|
Steps: map[string]*model.StepResult{
|
||||||
|
"step-id": {
|
||||||
|
Outputs: map[string]string{
|
||||||
|
"name": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"step-id2": {
|
||||||
|
Outcome: model.StepStatusFailure,
|
||||||
|
Conclusion: model.StepStatusSkipped,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Runner: map[string]any{
|
||||||
|
"os": "Linux",
|
||||||
|
"temp": "/tmp",
|
||||||
|
"tool_cache": "/opt/hostedtoolcache",
|
||||||
|
},
|
||||||
|
Secrets: map[string]string{
|
||||||
|
"name": "value",
|
||||||
|
},
|
||||||
|
Vars: map[string]string{
|
||||||
|
"name": "value",
|
||||||
|
},
|
||||||
|
Strategy: map[string]any{
|
||||||
|
"fail-fast": true,
|
||||||
|
},
|
||||||
|
Matrix: map[string]any{
|
||||||
|
"os": "Linux",
|
||||||
|
},
|
||||||
|
Needs: map[string]Needs{
|
||||||
|
"job-id": {
|
||||||
|
Outputs: map[string]string{
|
||||||
|
"output-name": "value",
|
||||||
|
},
|
||||||
|
Result: "success",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Inputs: map[string]any{
|
||||||
|
"name": "value",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, output)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoerceToString(t *testing.T) {
|
||||||
|
type object struct{ Name string }
|
||||||
|
obj := object{Name: "x"}
|
||||||
|
var nilPointer *object
|
||||||
|
var nilMap map[string]any
|
||||||
|
var nilSlice []any
|
||||||
|
|
||||||
|
table := []struct {
|
||||||
|
input any
|
||||||
|
expected string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{nil, "", "null"},
|
||||||
|
{true, "true", "true"},
|
||||||
|
{false, "false", "false"},
|
||||||
|
{"foo", "foo", "string"},
|
||||||
|
{"", "", "empty-string"},
|
||||||
|
{123, "123", "int"},
|
||||||
|
{int64(-9), "-9", "int64"},
|
||||||
|
{uint8(7), "7", "uint8"},
|
||||||
|
{1.0, "1", "float-integral"},
|
||||||
|
{-9.7, "-9.7", "float"},
|
||||||
|
{2.99e-2, "0.0299", "float-exponential"},
|
||||||
|
{1e21, "1E+21", "float-large"},
|
||||||
|
{float32(1.5), "1.5", "float32"},
|
||||||
|
{math.NaN(), "NaN", "nan"},
|
||||||
|
{math.Inf(1), "Infinity", "positive-infinity"},
|
||||||
|
{math.Inf(-1), "-Infinity", "negative-infinity"},
|
||||||
|
{[]any{1, 2}, "Array", "slice"},
|
||||||
|
{nilSlice, "Array", "nil-slice"},
|
||||||
|
{[2]int{1, 2}, "Array", "fixed-size-array"},
|
||||||
|
{map[string]any{"a": 1}, "Object", "map"},
|
||||||
|
{nilMap, "Object", "nil-map"},
|
||||||
|
{obj, "Object", "struct"},
|
||||||
|
{&obj, "Object", "pointer-to-struct"},
|
||||||
|
{nilPointer, "", "nil-pointer"},
|
||||||
|
{&model.GithubContext{Action: "push"}, "Object", "github-context"},
|
||||||
|
{reflect.ValueOf(42), "42", "reflected-value"},
|
||||||
|
{reflect.Value{}, "", "invalid-reflected-value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, CoerceToString(tt.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEmptyInputAsksItsOwnStatusCheck(t *testing.T) {
|
||||||
|
// always() needs no job or step context, so it shows which function an empty input asks for
|
||||||
|
output, err := NewInterpeter(&EvaluationEnvironment{}, Config{}).Evaluate("", DefaultStatusCheckAlways)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, true, output)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Hello
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
World!
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Knock knock!
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Anybody home?
|
||||||
@@ -97,25 +97,55 @@ type FileCollector struct {
|
|||||||
Ignorer gitignore.Matcher
|
Ignorer gitignore.Matcher
|
||||||
SrcPath string
|
SrcPath string
|
||||||
SrcPrefix string
|
SrcPrefix string
|
||||||
|
Fs Fs
|
||||||
Handler Handler
|
Handler Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func openGitIndex(path string) (*index.Index, error) {
|
type Fs interface {
|
||||||
repo, err := git.PlainOpen(path)
|
Walk(root string, fn filepath.WalkFunc) error
|
||||||
|
OpenGitIndex(path string) (*index.Index, error)
|
||||||
|
Open(path string) (io.ReadCloser, error)
|
||||||
|
Readlink(path string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultFs struct{}
|
||||||
|
|
||||||
|
func (*DefaultFs) Walk(root string, fn filepath.WalkFunc) error {
|
||||||
|
return filepath.Walk(root, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DefaultFs) OpenGitIndex(path string) (*index.Index, error) {
|
||||||
|
r, err := git.PlainOpen(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return repo.Storer.Index()
|
i, err := r.Storer.Index()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DefaultFs) Open(path string) (io.ReadCloser, error) {
|
||||||
|
return os.Open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DefaultFs) Readlink(path string) (string, error) {
|
||||||
|
return os.Readlink(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []string) filepath.WalkFunc {
|
func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []string) filepath.WalkFunc {
|
||||||
i, _ := openGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
|
i, _ := fc.Fs.OpenGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
|
||||||
return func(file string, fi os.FileInfo, err error) error {
|
return func(file string, fi os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ctx != nil && ctx.Err() != nil {
|
if ctx != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
return errors.New("copy cancelled")
|
return errors.New("copy cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sansPrefix := strings.TrimPrefix(file, fc.SrcPrefix)
|
sansPrefix := strings.TrimPrefix(file, fc.SrcPrefix)
|
||||||
@@ -145,7 +175,7 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err == nil && entry.Mode == filemode.Submodule {
|
if err == nil && entry.Mode == filemode.Submodule {
|
||||||
err = filepath.Walk(file, fc.CollectFiles(ctx, split))
|
err = fc.Fs.Walk(file, fc.CollectFiles(ctx, split))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -155,7 +185,7 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
|||||||
|
|
||||||
// return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update)
|
// return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update)
|
||||||
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
|
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||||
linkName, err := os.Readlink(file)
|
linkName, err := fc.Fs.Readlink(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to readlink '%s': %w", file, err)
|
return fmt.Errorf("unable to readlink '%s': %w", file, err)
|
||||||
}
|
}
|
||||||
@@ -165,15 +195,23 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// open file
|
// open file
|
||||||
f, err := os.Open(file)
|
f, err := fc.Fs.Open(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
if ctx != nil {
|
if ctx != nil {
|
||||||
stop := context.AfterFunc(ctx, func() { _ = f.Close() })
|
// make io.Copy cancellable by closing the file
|
||||||
defer stop()
|
cpctx, cpfinish := context.WithCancel(ctx)
|
||||||
|
defer cpfinish()
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-cpctx.Done():
|
||||||
|
case <-ctx.Done():
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return fc.Handler.WriteFile(path, fi, "", f)
|
return fc.Handler.WriteFile(path, fi, "", f)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package filecollector
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,41 +13,110 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-git/go-billy/v5"
|
||||||
|
"github.com/go-git/go-billy/v5/memfs"
|
||||||
git "github.com/go-git/go-git/v5"
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/cache"
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/format/index"
|
||||||
|
"github.com/go-git/go-git/v5/storage/filesystem"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIgnoredTrackedfile(t *testing.T) {
|
type memoryFs struct {
|
||||||
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
|
billy.Filesystem
|
||||||
repo, err := git.PlainInit(repoDir, false)
|
}
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".gitignore"), []byte(".*\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".env"), []byte("test=val1\n"), 0o644))
|
|
||||||
worktree, err := repo.Worktree()
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = worktree.Add(".gitignore")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var archive bytes.Buffer
|
func (mfs *memoryFs) walk(root string, fn filepath.WalkFunc) error {
|
||||||
tw := tar.NewWriter(&archive)
|
dir, err := mfs.ReadDir(root)
|
||||||
patterns, err := gitignore.ReadPatterns(worktree.Filesystem, nil)
|
if err != nil {
|
||||||
require.NoError(t, err)
|
return err
|
||||||
ignorer := gitignore.NewMatcher(patterns)
|
}
|
||||||
|
for i := range dir {
|
||||||
|
filename := filepath.Join(root, dir[i].Name())
|
||||||
|
err = fn(filename, dir[i], nil)
|
||||||
|
if dir[i].IsDir() {
|
||||||
|
if err == filepath.SkipDir {
|
||||||
|
err = nil
|
||||||
|
} else if err := mfs.walk(filename, fn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfs *memoryFs) Walk(root string, fn filepath.WalkFunc) error {
|
||||||
|
stat, err := mfs.Lstat(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = fn(strings.Join([]string{root, "."}, string(filepath.Separator)), stat, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return mfs.walk(root, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfs *memoryFs) OpenGitIndex(path string) (*index.Index, error) {
|
||||||
|
f, _ := mfs.Filesystem.Chroot(filepath.Join(path, ".git")) //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
|
storage := filesystem.NewStorage(f, cache.NewObjectLRUDefault())
|
||||||
|
i, err := storage.Index()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfs *memoryFs) Open(path string) (io.ReadCloser, error) {
|
||||||
|
return mfs.Filesystem.Open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfs *memoryFs) Readlink(path string) (string, error) {
|
||||||
|
return mfs.Filesystem.Readlink(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIgnoredTrackedfile(t *testing.T) {
|
||||||
|
fs := memfs.New()
|
||||||
|
_ = fs.MkdirAll("mygitrepo/.git", 0o777)
|
||||||
|
dotgit, _ := fs.Chroot("mygitrepo/.git")
|
||||||
|
worktree, _ := fs.Chroot("mygitrepo")
|
||||||
|
repo, _ := git.Init(filesystem.NewStorage(dotgit, cache.NewObjectLRUDefault()), worktree)
|
||||||
|
f, _ := worktree.Create(".gitignore")
|
||||||
|
_, _ = f.Write([]byte(".*\n"))
|
||||||
|
f.Close()
|
||||||
|
// This file shouldn't be in the tar
|
||||||
|
f, _ = worktree.Create(".env")
|
||||||
|
_, _ = f.Write([]byte("test=val1\n"))
|
||||||
|
f.Close()
|
||||||
|
w, _ := repo.Worktree()
|
||||||
|
// .gitignore is in the tar after adding it to the index
|
||||||
|
_, _ = w.Add(".gitignore")
|
||||||
|
|
||||||
|
tmpTar, _ := fs.Create("temp.tar")
|
||||||
|
tw := tar.NewWriter(tmpTar)
|
||||||
|
ps, _ := gitignore.ReadPatterns(worktree, []string{})
|
||||||
|
ignorer := gitignore.NewMatcher(ps)
|
||||||
fc := &FileCollector{
|
fc := &FileCollector{
|
||||||
|
Fs: &memoryFs{Filesystem: fs},
|
||||||
Ignorer: ignorer,
|
Ignorer: ignorer,
|
||||||
SrcPath: repoDir,
|
SrcPath: "mygitrepo",
|
||||||
SrcPrefix: repoDir + string(filepath.Separator),
|
SrcPrefix: "mygitrepo" + string(filepath.Separator),
|
||||||
Handler: &TarCollector{
|
Handler: &TarCollector{
|
||||||
TarWriter: tw,
|
TarWriter: tw,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
err = filepath.Walk(repoDir, fc.CollectFiles(context.Background(), nil))
|
err := fc.Fs.Walk("mygitrepo", fc.CollectFiles(context.Background(), []string{}))
|
||||||
assert.NoError(t, err, "successfully collect files")
|
assert.NoError(t, err, "successfully collect files") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
require.NoError(t, tw.Close())
|
tw.Close()
|
||||||
tr := tar.NewReader(&archive)
|
_, _ = tmpTar.Seek(0, io.SeekStart)
|
||||||
|
tr := tar.NewReader(tmpTar)
|
||||||
h, err := tr.Next()
|
h, err := tr.Next()
|
||||||
assert.NoError(t, err, "tar must not be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "tar must not be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, ".gitignore", h.Name)
|
assert.Equal(t, ".gitignore", h.Name)
|
||||||
@@ -57,32 +125,47 @@ func TestIgnoredTrackedfile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSymlinks(t *testing.T) {
|
func TestSymlinks(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
fs := memfs.New()
|
||||||
t.Skip("creating symlinks requires elevated privileges on Windows")
|
_ = fs.MkdirAll("mygitrepo/.git", 0o777)
|
||||||
}
|
dotgit, _ := fs.Chroot("mygitrepo/.git")
|
||||||
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
|
worktree, _ := fs.Chroot("mygitrepo")
|
||||||
repo, err := git.PlainInit(repoDir, false)
|
repo, _ := git.Init(filesystem.NewStorage(dotgit, cache.NewObjectLRUDefault()), worktree)
|
||||||
require.NoError(t, err)
|
// This file shouldn't be in the tar
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".env"), []byte("test=val1\n"), 0o644))
|
f, err := worktree.Create(".env")
|
||||||
require.NoError(t, os.Symlink(".env", filepath.Join(repoDir, "test.env")))
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
worktree, err := repo.Worktree()
|
_, err = f.Write([]byte("test=val1\n"))
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
_, err = worktree.Add("test.env")
|
f.Close()
|
||||||
require.NoError(t, err)
|
err = worktree.Symlink(".env", "test.env")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
var archive bytes.Buffer
|
w, err := repo.Worktree()
|
||||||
tw := tar.NewWriter(&archive)
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// .gitignore is in the tar after adding it to the index
|
||||||
|
_, err = w.Add(".env")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
_, err = w.Add("test.env")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
tmpTar, _ := fs.Create("temp.tar")
|
||||||
|
tw := tar.NewWriter(tmpTar)
|
||||||
|
ps, _ := gitignore.ReadPatterns(worktree, []string{})
|
||||||
|
ignorer := gitignore.NewMatcher(ps)
|
||||||
fc := &FileCollector{
|
fc := &FileCollector{
|
||||||
SrcPath: repoDir,
|
Fs: &memoryFs{Filesystem: fs},
|
||||||
SrcPrefix: repoDir + string(filepath.Separator),
|
Ignorer: ignorer,
|
||||||
|
SrcPath: "mygitrepo",
|
||||||
|
SrcPrefix: "mygitrepo" + string(filepath.Separator),
|
||||||
Handler: &TarCollector{
|
Handler: &TarCollector{
|
||||||
TarWriter: tw,
|
TarWriter: tw,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
err = filepath.Walk(repoDir, fc.CollectFiles(context.Background(), nil))
|
err = fc.Fs.Walk("mygitrepo", fc.CollectFiles(context.Background(), []string{}))
|
||||||
assert.NoError(t, err, "successfully collect files")
|
assert.NoError(t, err, "successfully collect files") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
require.NoError(t, tw.Close())
|
tw.Close()
|
||||||
tr := tar.NewReader(&archive)
|
_, _ = tmpTar.Seek(0, io.SeekStart)
|
||||||
|
tr := tar.NewReader(tmpTar)
|
||||||
h, err := tr.Next()
|
h, err := tr.Next()
|
||||||
files := map[string]tar.Header{}
|
files := map[string]tar.Header{}
|
||||||
for err == nil {
|
for err == nil {
|
||||||
@@ -140,14 +223,62 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
|||||||
assert.Equal(t, "target", resolved)
|
assert.Equal(t, "target", resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
|
func TestDefaultFsOpenReadlinkAndWalk(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
if runtime.GOOS == "windows" {
|
||||||
cancel()
|
t.Skip("creating symlinks requires elevated privileges on Windows")
|
||||||
walk := (&FileCollector{}).CollectFiles(ctx, nil)
|
}
|
||||||
|
|
||||||
err := walk("file", nil, nil)
|
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")
|
require.EqualError(t, err, "copy cancelled")
|
||||||
|
|
||||||
err = walk("file", nil, os.ErrPermission)
|
err = walk("file", fakeFileInfo{name: "file"}, os.ErrPermission)
|
||||||
require.ErrorIs(t, err, 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 }
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
// Package ghcontext fills a model.GithubContext from the local git checkout.
|
|
||||||
// The pure data parts of the context live in the shared
|
|
||||||
// gitea.dev/actionslib/pkg/model package, only the helpers that need a
|
|
||||||
// git repository on disk are kept here.
|
|
||||||
package ghcontext
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
findGitRef = git.FindGitRef
|
|
||||||
findGitRevision = git.FindGitRevision
|
|
||||||
findGithubRepo = git.FindGithubRepo
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetRef resolves the ref of the context from its event payload, falling back
|
|
||||||
// to the ref checked out in repoPath.
|
|
||||||
func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
|
||||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
|
||||||
switch ghc.EventName {
|
|
||||||
case "pull_request_target":
|
|
||||||
ghc.Ref = "refs/heads/" + ghc.BaseRef
|
|
||||||
case "pull_request", "pull_request_review", "pull_request_review_comment":
|
|
||||||
ghc.Ref = fmt.Sprintf("refs/pull/%.0f/merge", ghc.Event["number"])
|
|
||||||
case "deployment", "deployment_status":
|
|
||||||
ghc.Ref = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "ref"))
|
|
||||||
case "release":
|
|
||||||
ghc.Ref = "refs/tags/" + model.AsString(model.NestedMapLookup(ghc.Event, "release", "tag_name"))
|
|
||||||
case "push", "create", "workflow_dispatch":
|
|
||||||
ghc.Ref = model.AsString(ghc.Event["ref"])
|
|
||||||
default:
|
|
||||||
defaultBranch := model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
|
||||||
if defaultBranch != "" {
|
|
||||||
ghc.Ref = "refs/heads/" + defaultBranch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.Ref == "" {
|
|
||||||
ref, err := findGitRef(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("unable to get git ref: %v", err)
|
|
||||||
} else {
|
|
||||||
logger.Debugf("using github ref: %s", ref)
|
|
||||||
ghc.Ref = ref
|
|
||||||
}
|
|
||||||
|
|
||||||
repository, exists := ghc.Event["repository"]
|
|
||||||
if !exists {
|
|
||||||
repository = map[string]any{}
|
|
||||||
}
|
|
||||||
if repository, ok := repository.(map[string]any); !ok {
|
|
||||||
logger.Warn("unable to set default branch to master")
|
|
||||||
} else if _, exists := repository["default_branch"]; !exists {
|
|
||||||
repository["default_branch"] = "master"
|
|
||||||
ghc.Event["repository"] = repository
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.Ref == "" {
|
|
||||||
ghc.Ref = "refs/heads/" + model.AsString(model.NestedMapLookup(ghc.Event, "repository", "default_branch"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSha resolves the commit of the context from its event payload, falling
|
|
||||||
// back to the revision checked out in repoPath.
|
|
||||||
func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
|
||||||
// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads
|
|
||||||
switch ghc.EventName {
|
|
||||||
case "pull_request_target":
|
|
||||||
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "pull_request", "base", "sha"))
|
|
||||||
case "deployment", "deployment_status":
|
|
||||||
ghc.Sha = model.AsString(model.NestedMapLookup(ghc.Event, "deployment", "sha"))
|
|
||||||
case "push", "create", "workflow_dispatch":
|
|
||||||
if deleted, ok := ghc.Event["deleted"].(bool); ok && !deleted {
|
|
||||||
ghc.Sha = model.AsString(ghc.Event["after"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ghc.Sha == "" {
|
|
||||||
_, sha, err := 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, repoPath string) {
|
|
||||||
if ghc.Repository == "" {
|
|
||||||
repo, err := findGithubRepo(ctx, repoPath, githubInstance)
|
|
||||||
if err != nil {
|
|
||||||
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v, repoPath: %v): %v", githubInstance, repoPath, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ghc.Repository = repo
|
|
||||||
}
|
|
||||||
ghc.RepositoryOwner = strings.Split(ghc.Repository, "/")[0]
|
|
||||||
}
|
|
||||||
+2
-14
@@ -4,18 +4,6 @@
|
|||||||
|
|
||||||
package lookpath
|
package lookpath
|
||||||
|
|
||||||
import (
|
type Env interface {
|
||||||
"runtime"
|
Getenv(name string) string
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getenv(env map[string]string, name string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
for key, value := range env {
|
|
||||||
if strings.EqualFold(name, key) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return env[name]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ var ErrNotFound = errors.New("executable file not found in $PATH")
|
|||||||
// directories named by the PATH environment variable.
|
// directories named by the PATH environment variable.
|
||||||
// If file contains a slash, it is tried directly and the PATH is not consulted.
|
// If file contains a slash, it is tried directly and the PATH is not consulted.
|
||||||
// The result may be an absolute path or a path relative to the current directory.
|
// The result may be an absolute path or a path relative to the current directory.
|
||||||
func LookPath2(file string, _ map[string]string) (string, error) {
|
func LookPath2(file string, lenv Env) (string, error) {
|
||||||
// Wasm can not execute processes, so act as if there are no executables at all.
|
// Wasm can not execute processes, so act as if there are no executables at all.
|
||||||
return "", &Error{file, ErrNotFound}
|
return "", &Error{file, ErrNotFound}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func findExecutable(file string) error {
|
|||||||
// If file begins with "/", "#", "./", or "../", it is tried
|
// If file begins with "/", "#", "./", or "../", it is tried
|
||||||
// directly and the path is not consulted.
|
// directly and the path is not consulted.
|
||||||
// The result may be an absolute path or a path relative to the current directory.
|
// The result may be an absolute path or a path relative to the current directory.
|
||||||
func LookPath2(file string, env map[string]string) (string, error) {
|
func LookPath2(file string, lenv Env) (string, error) {
|
||||||
// skip the path lookup for these prefixes
|
// skip the path lookup for these prefixes
|
||||||
skip := []string{"/", "#", "./", "../"}
|
skip := []string{"/", "#", "./", "../"}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ func LookPath2(file string, env map[string]string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
path := getenv(env, "path")
|
path := lenv.Getenv("path")
|
||||||
for _, dir := range filepath.SplitList(path) {
|
for _, dir := range filepath.SplitList(path) {
|
||||||
path := filepath.Join(dir, file)
|
path := filepath.Join(dir, file)
|
||||||
if err := findExecutable(path); err == nil {
|
if err := findExecutable(path); err == nil {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func findExecutable(file string) error {
|
|||||||
// directories named by the PATH environment variable.
|
// directories named by the PATH environment variable.
|
||||||
// If file contains a slash, it is tried directly and the PATH is not consulted.
|
// If file contains a slash, it is tried directly and the PATH is not consulted.
|
||||||
// The result may be an absolute path or a path relative to the current directory.
|
// The result may be an absolute path or a path relative to the current directory.
|
||||||
func LookPath2(file string, env map[string]string) (string, error) {
|
func LookPath2(file string, lenv Env) (string, error) {
|
||||||
// NOTE(rsc): I wish we could use the Plan 9 behavior here
|
// NOTE(rsc): I wish we could use the Plan 9 behavior here
|
||||||
// (only bypass the path if file begins with / or ./ or ../)
|
// (only bypass the path if file begins with / or ./ or ../)
|
||||||
// but that would not match all the Unix shells.
|
// but that would not match all the Unix shells.
|
||||||
@@ -45,7 +45,7 @@ func LookPath2(file string, env map[string]string) (string, error) {
|
|||||||
}
|
}
|
||||||
return "", &Error{file, err}
|
return "", &Error{file, err}
|
||||||
}
|
}
|
||||||
path := getenv(env, "PATH")
|
path := lenv.Getenv("PATH")
|
||||||
for _, dir := range filepath.SplitList(path) {
|
for _, dir := range filepath.SplitList(path) {
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
// Unix shell semantics: path element "" means "."
|
// Unix shell semantics: path element "" means "."
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type testEnv map[string]string
|
||||||
|
|
||||||
|
func (e testEnv) Getenv(name string) string {
|
||||||
|
return e[name]
|
||||||
|
}
|
||||||
|
|
||||||
func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
|
func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
exe := filepath.Join(dir, "tool")
|
exe := filepath.Join(dir, "tool")
|
||||||
@@ -20,7 +26,7 @@ func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := LookPath2("tool", map[string]string{"PATH": string(filepath.ListSeparator) + dir})
|
got, err := LookPath2("tool", testEnv{"PATH": string(filepath.ListSeparator) + dir})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -36,7 +42,7 @@ func TestLookPath2DirectPathDoesNotSearchPath(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := LookPath2(exe, map[string]string{"PATH": ""})
|
got, err := LookPath2(exe, testEnv{"PATH": ""})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -52,7 +58,7 @@ func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := LookPath2(file, map[string]string{"PATH": dir})
|
_, err := LookPath2(file, testEnv{"PATH": dir})
|
||||||
var pathErr *Error
|
var pathErr *Error
|
||||||
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, fs.ErrPermission) {
|
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)
|
t.Fatalf("LookPath2(non-executable) error = %v, want fs.ErrPermission wrapped in *Error", err)
|
||||||
@@ -61,7 +67,7 @@ func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
|
|||||||
t.Fatalf("Error() = %q, want %q", pathErr.Error(), fs.ErrPermission.Error())
|
t.Fatalf("Error() = %q, want %q", pathErr.Error(), fs.ErrPermission.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = LookPath2("missing", map[string]string{"PATH": dir})
|
_, err = LookPath2("missing", testEnv{"PATH": dir})
|
||||||
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, ErrNotFound) {
|
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, ErrNotFound) {
|
||||||
t.Fatalf("LookPath2(missing) error = %v, want ErrNotFound wrapped in *Error", err)
|
t.Fatalf("LookPath2(missing) error = %v, want ErrNotFound wrapped in *Error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ func findExecutable(file string, exts []string) (string, error) {
|
|||||||
// LookPath also uses PATHEXT environment variable to match
|
// LookPath also uses PATHEXT environment variable to match
|
||||||
// a suitable candidate.
|
// a suitable candidate.
|
||||||
// The result may be an absolute path or a path relative to the current directory.
|
// The result may be an absolute path or a path relative to the current directory.
|
||||||
func LookPath2(file string, env map[string]string) (string, error) {
|
func LookPath2(file string, lenv Env) (string, error) {
|
||||||
var exts []string
|
var exts []string
|
||||||
x := getenv(env, `PATHEXT`)
|
x := lenv.Getenv(`PATHEXT`)
|
||||||
if x != "" {
|
if x != "" {
|
||||||
for e := range strings.SplitSeq(strings.ToLower(x), `;`) {
|
for e := range strings.SplitSeq(strings.ToLower(x), `;`) {
|
||||||
if e == "" {
|
if e == "" {
|
||||||
@@ -85,7 +85,7 @@ func LookPath2(file string, env map[string]string) (string, error) {
|
|||||||
if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
|
if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
path := getenv(env, "path")
|
path := lenv.Getenv("path")
|
||||||
for _, dir := range filepath.SplitList(path) {
|
for _, dir := range filepath.SplitList(path) {
|
||||||
if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil {
|
if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil {
|
||||||
return f, nil
|
return f, nil
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionRunsUsing is the type of runner for the action
|
||||||
|
type ActionRunsUsing string
|
||||||
|
|
||||||
|
func (a *ActionRunsUsing) UnmarshalYAML(unmarshal func(any) error) error {
|
||||||
|
var using string
|
||||||
|
if err := unmarshal(&using); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force input to lowercase for case insensitive comparison
|
||||||
|
format := ActionRunsUsing(strings.ToLower(using))
|
||||||
|
switch format {
|
||||||
|
case ActionRunsUsingNode24, ActionRunsUsingNode20, ActionRunsUsingNode16, ActionRunsUsingNode12, ActionRunsUsingDocker, ActionRunsUsingComposite, ActionRunsUsingGo:
|
||||||
|
*a = format
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("The runs.using key in action.yml must be one of: %v, got %s", []string{
|
||||||
|
ActionRunsUsingComposite,
|
||||||
|
ActionRunsUsingDocker,
|
||||||
|
ActionRunsUsingNode12,
|
||||||
|
ActionRunsUsingNode16,
|
||||||
|
ActionRunsUsingNode20,
|
||||||
|
ActionRunsUsingNode24,
|
||||||
|
ActionRunsUsingGo,
|
||||||
|
}, format)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ActionRunsUsingNode12 for running with node12
|
||||||
|
ActionRunsUsingNode12 = "node12"
|
||||||
|
// ActionRunsUsingNode16 for running with node16
|
||||||
|
ActionRunsUsingNode16 = "node16"
|
||||||
|
// ActionRunsUsingNode20 for running with node20
|
||||||
|
ActionRunsUsingNode20 = "node20"
|
||||||
|
// ActionRunsUsingNode24 for running with node24
|
||||||
|
ActionRunsUsingNode24 = "node24"
|
||||||
|
// ActionRunsUsingDocker for running with docker
|
||||||
|
ActionRunsUsingDocker = "docker"
|
||||||
|
// ActionRunsUsingComposite for running composite
|
||||||
|
ActionRunsUsingComposite = "composite"
|
||||||
|
// ActionRunsUsingGo for running with go
|
||||||
|
ActionRunsUsingGo = "go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a ActionRunsUsing) IsNode() bool {
|
||||||
|
switch a {
|
||||||
|
case ActionRunsUsingNode12, ActionRunsUsingNode16, ActionRunsUsingNode20, ActionRunsUsingNode24:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a ActionRunsUsing) IsDocker() bool {
|
||||||
|
return a == ActionRunsUsingDocker
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a ActionRunsUsing) IsComposite() bool {
|
||||||
|
return a == ActionRunsUsingComposite
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionRuns are a field in Action
|
||||||
|
type ActionRuns struct {
|
||||||
|
Using ActionRunsUsing `yaml:"using"`
|
||||||
|
Env map[string]string `yaml:"env"`
|
||||||
|
Main string `yaml:"main"`
|
||||||
|
Pre string `yaml:"pre"`
|
||||||
|
PreIf string `yaml:"pre-if"`
|
||||||
|
Post string `yaml:"post"`
|
||||||
|
PostIf string `yaml:"post-if"`
|
||||||
|
Image string `yaml:"image"`
|
||||||
|
PreEntrypoint string `yaml:"pre-entrypoint"`
|
||||||
|
Entrypoint string `yaml:"entrypoint"`
|
||||||
|
PostEntrypoint string `yaml:"post-entrypoint"`
|
||||||
|
Args []string `yaml:"args"`
|
||||||
|
Steps []Step `yaml:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.
|
||||||
|
type Action struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Author string `yaml:"author"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Inputs map[string]Input `yaml:"inputs"`
|
||||||
|
Outputs map[string]Output `yaml:"outputs"`
|
||||||
|
Runs ActionRuns `yaml:"runs"`
|
||||||
|
Branding struct {
|
||||||
|
Color string `yaml:"color"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
} `yaml:"branding"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.
|
||||||
|
type Input struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions. For example, if you had an action that performed the addition of two inputs (x + y = z), the action could output the sum (z) for other actions to use as an input.
|
||||||
|
type Output struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAction reads an action from a reader
|
||||||
|
func ReadAction(in io.Reader) (*Action, error) {
|
||||||
|
a := new(Action)
|
||||||
|
err := yaml.NewDecoder(in).Decode(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// set defaults
|
||||||
|
if a.Runs.PreIf == "" {
|
||||||
|
a.Runs.PreIf = "always()"
|
||||||
|
}
|
||||||
|
if a.Runs.PostIf == "" {
|
||||||
|
a.Runs.PostIf = "always()"
|
||||||
|
}
|
||||||
|
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadActionDefaultsAndCaseInsensitiveUsing(t *testing.T) {
|
||||||
|
action, err := ReadAction(strings.NewReader(`
|
||||||
|
name: example
|
||||||
|
runs:
|
||||||
|
using: NoDe24
|
||||||
|
main: dist/index.js
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Runs.Using != ActionRunsUsingNode24 {
|
||||||
|
t.Fatalf("using = %q, want %q", action.Runs.Using, ActionRunsUsingNode24)
|
||||||
|
}
|
||||||
|
if action.Runs.PreIf != "always()" {
|
||||||
|
t.Fatalf("pre-if = %q, want always()", action.Runs.PreIf)
|
||||||
|
}
|
||||||
|
if action.Runs.PostIf != "always()" {
|
||||||
|
t.Fatalf("post-if = %q, want always()", action.Runs.PostIf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadActionPreservesExplicitConditions(t *testing.T) {
|
||||||
|
action, err := ReadAction(strings.NewReader(`
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
pre-if: success()
|
||||||
|
post-if: failure()
|
||||||
|
steps:
|
||||||
|
- run: echo hello
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Runs.PreIf != "success()" || action.Runs.PostIf != "failure()" {
|
||||||
|
t.Fatalf("conditions = %q/%q, want explicit values", action.Runs.PreIf, action.Runs.PostIf)
|
||||||
|
}
|
||||||
|
if !action.Runs.Using.IsComposite() || action.Runs.Using.IsDocker() || action.Runs.Using.IsNode() {
|
||||||
|
t.Fatalf("unexpected using predicates for %q", action.Runs.Using)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadActionRejectsUnknownUsing(t *testing.T) {
|
||||||
|
_, err := ReadAction(strings.NewReader(`
|
||||||
|
runs:
|
||||||
|
using: node99
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unknown runs.using to fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "node99") {
|
||||||
|
t.Fatalf("error = %q, want invalid value", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadActionDockerEntrypoints(t *testing.T) {
|
||||||
|
action, err := ReadAction(strings.NewReader(`
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
pre-entrypoint: pre.sh
|
||||||
|
post-entrypoint: post.sh
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Runs.PreEntrypoint != "pre.sh" {
|
||||||
|
t.Fatalf("pre-entrypoint = %q, want pre.sh", action.Runs.PreEntrypoint)
|
||||||
|
}
|
||||||
|
if action.Runs.PostEntrypoint != "post.sh" {
|
||||||
|
t.Fatalf("post-entrypoint = %q, want post.sh", action.Runs.PostEntrypoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,13 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package ghcontext
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -98,13 +97,13 @@ func TestSetRef(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.eventName, func(t *testing.T) {
|
t.Run(table.eventName, func(t *testing.T) {
|
||||||
ghc := &model.GithubContext{
|
ghc := &GithubContext{
|
||||||
EventName: table.eventName,
|
EventName: table.eventName,
|
||||||
BaseRef: "master",
|
BaseRef: "master",
|
||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRef(context.Background(), ghc, "/some/dir")
|
ghc.SetRef(context.Background(), "main", "/some/dir")
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|
||||||
assert.Equal(t, table.ref, ghc.Ref)
|
assert.Equal(t, table.ref, ghc.Ref)
|
||||||
@@ -117,12 +116,12 @@ func TestSetRef(t *testing.T) {
|
|||||||
return "", errors.New("no default branch")
|
return "", errors.New("no default branch")
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc := &model.GithubContext{
|
ghc := &GithubContext{
|
||||||
EventName: "no-default-branch",
|
EventName: "no-default-branch",
|
||||||
Event: map[string]any{},
|
Event: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRef(context.Background(), ghc, "/some/dir")
|
ghc.SetRef(context.Background(), "", "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
||||||
})
|
})
|
||||||
@@ -203,13 +202,13 @@ func TestSetSha(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.eventName, func(t *testing.T) {
|
t.Run(table.eventName, func(t *testing.T) {
|
||||||
ghc := &model.GithubContext{
|
ghc := &GithubContext{
|
||||||
EventName: table.eventName,
|
EventName: table.eventName,
|
||||||
BaseRef: "master",
|
BaseRef: "master",
|
||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
SetSha(context.Background(), ghc, "/some/dir")
|
ghc.SetSha(context.Background(), "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, table.sha, ghc.Sha)
|
assert.Equal(t, table.sha, ghc.Sha)
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// 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 JobContainerContext `json:"container"`
|
||||||
|
Services map[string]JobService `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobContainerContext struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Network string `json:"network"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobService struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Network string `json:"network"`
|
||||||
|
Ports map[string]string `json:"ports"` // container port to the published host port
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkflowPlanTest struct {
|
||||||
|
workflowPath string
|
||||||
|
errorMessage string
|
||||||
|
noWorkflowRecurse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanner(t *testing.T) {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
|
tables := []WorkflowPlanTest{
|
||||||
|
{"invalid-job-name/invalid-1.yml", "workflow is not valid. 'invalid-job-name-1': Job name 'invalid-JOB-Name-v1.2.3-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
||||||
|
{"invalid-job-name/invalid-2.yml", "workflow is not valid. 'invalid-job-name-2': Job name '1234invalid-JOB-Name-v123-docker_hub' is invalid. Names must start with a letter or '_' and contain only alphanumeric characters, '-', or '_'", false},
|
||||||
|
{"invalid-job-name/valid-1.yml", "", false},
|
||||||
|
{"invalid-job-name/valid-2.yml", "", false},
|
||||||
|
{"empty-workflow", "unable to read workflow 'push.yml': file is empty: EOF", false},
|
||||||
|
{"nested", "unable to read workflow 'fail.yml': file is empty: EOF", false},
|
||||||
|
{"nested", "", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
workdir, err := filepath.Abs("testdata")
|
||||||
|
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
for _, table := range tables {
|
||||||
|
fullWorkflowPath := filepath.Join(workdir, table.workflowPath)
|
||||||
|
_, err = NewWorkflowPlanner(fullWorkflowPath, table.noWorkflowRecurse)
|
||||||
|
if table.errorMessage == "" {
|
||||||
|
assert.NoError(t, err, "WorkflowPlanner should exit without any error")
|
||||||
|
} else {
|
||||||
|
assert.EqualError(t, err, table.errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflow(t *testing.T) {
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
|
workflow := Workflow{
|
||||||
|
Jobs: map[string]*Job{
|
||||||
|
"valid_job": {
|
||||||
|
Name: "valid_job",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that an invalid job id returns error
|
||||||
|
result, err := createStages(&workflow, "invalid_job_id")
|
||||||
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Nil(t, result)
|
||||||
|
|
||||||
|
// Check that an valid job id returns non-error
|
||||||
|
result, err = createStages(&workflow, "valid_job")
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSingleWorkflowPlannerAndPlanMethods(t *testing.T) {
|
||||||
|
planner, err := NewSingleWorkflowPlanner("ci.yml", strings.NewReader(`
|
||||||
|
name: CI
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build project
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make build
|
||||||
|
test:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make test
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"pull_request", "push"}, planner.GetEvents())
|
||||||
|
|
||||||
|
eventPlan, err := planner.PlanEvent("push")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, eventPlan.Stages, 2)
|
||||||
|
assert.Equal(t, []string{"build"}, eventPlan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, eventPlan.Stages[1].GetJobIDs())
|
||||||
|
assert.Equal(t, len("Build project"), eventPlan.MaxRunNameLen())
|
||||||
|
assert.Equal(t, "Build project", eventPlan.Stages[0].Runs[0].String())
|
||||||
|
assert.Equal(t, "build", eventPlan.Stages[0].Runs[0].JobID)
|
||||||
|
assert.NotNil(t, eventPlan.Stages[0].Runs[0].Job())
|
||||||
|
|
||||||
|
jobPlan, err := planner.PlanJob("test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jobPlan.Stages, 2)
|
||||||
|
assert.Equal(t, []string{"build"}, jobPlan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, jobPlan.Stages[1].GetJobIDs())
|
||||||
|
|
||||||
|
allPlan, err := planner.PlanAll()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, allPlan.Stages, 2)
|
||||||
|
assert.Equal(t, []string{"build"}, allPlan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, allPlan.Stages[1].GetJobIDs())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineWorkflowPlannerMergesWorkflowStages(t *testing.T) {
|
||||||
|
first := mustReadWorkflow(t, `
|
||||||
|
name: First
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make build
|
||||||
|
`)
|
||||||
|
second := mustReadWorkflow(t, `
|
||||||
|
name: Second
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make lint
|
||||||
|
test:
|
||||||
|
needs: lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make test
|
||||||
|
`)
|
||||||
|
|
||||||
|
planner := CombineWorkflowPlanner(first, second)
|
||||||
|
plan, err := planner.PlanEvent("push")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Stages, 2)
|
||||||
|
assert.ElementsMatch(t, []string{"build", "lint"}, plan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, plan.Stages[1].GetJobIDs())
|
||||||
|
|
||||||
|
empty, err := planner.PlanEvent("schedule")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, empty.Stages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlannerErrorsForMissingAndCyclicJobs(t *testing.T) {
|
||||||
|
workflow := mustReadWorkflow(t, `
|
||||||
|
name: Cyclic
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
a:
|
||||||
|
needs: b
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo a
|
||||||
|
b:
|
||||||
|
needs: a
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo b
|
||||||
|
`)
|
||||||
|
planner := CombineWorkflowPlanner(workflow)
|
||||||
|
|
||||||
|
plan, err := planner.PlanJob("missing")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, plan.Stages)
|
||||||
|
assert.Contains(t, err.Error(), "Could not find any stages")
|
||||||
|
|
||||||
|
plan, err = planner.PlanEvent("push")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, plan.Stages)
|
||||||
|
assert.Contains(t, err.Error(), "unable to build dependency graph")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSingleWorkflowPlannerErrors(t *testing.T) {
|
||||||
|
_, err := NewSingleWorkflowPlanner("empty.yml", strings.NewReader(""))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "file is empty")
|
||||||
|
|
||||||
|
_, err = NewSingleWorkflowPlanner("invalid.yml", strings.NewReader("jobs: ["))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "workflow is not valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadWorkflow(t *testing.T, content string) *Workflow {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
workflow, err := ReadWorkflow(strings.NewReader(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if workflow.Name == "" {
|
||||||
|
workflow.Name = "workflow"
|
||||||
|
}
|
||||||
|
return workflow
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 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"`
|
||||||
|
}
|
||||||
Vendored
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
name: invalid-job-name-2
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
1234invalid-JOB-Name-v123-docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
name: valid-job-name-1
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
valid-JOB-Name-v123-docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
name: valid-job-name-2
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
___valid-JOB-Name-v123-docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
name: Hello World Workflow
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
hello-world:
|
||||||
|
name: Hello World Job
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "Hello World!"
|
||||||
Vendored
+50
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,916 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Workflow is the structure of the files in .github/workflows
|
||||||
|
type Workflow struct {
|
||||||
|
File string
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
RawOn yaml.Node `yaml:"on"`
|
||||||
|
Env map[string]string `yaml:"env"`
|
||||||
|
Jobs map[string]*Job `yaml:"jobs"`
|
||||||
|
Defaults Defaults `yaml:"defaults"`
|
||||||
|
RawConcurrency *RawConcurrency `yaml:"concurrency"`
|
||||||
|
RawPermissions yaml.Node `yaml:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// On events for the workflow
|
||||||
|
func (w *Workflow) On() []string {
|
||||||
|
switch w.RawOn.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var val string
|
||||||
|
err := w.RawOn.Decode(&val)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return []string{val}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var val []string
|
||||||
|
err := w.RawOn.Decode(&val)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
case yaml.MappingNode:
|
||||||
|
var val map[string]any
|
||||||
|
err := w.RawOn.Decode(&val)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
var keys []string
|
||||||
|
for k := range val {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) OnEvent(event string) any {
|
||||||
|
if w.RawOn.Kind == yaml.MappingNode {
|
||||||
|
var val map[string]any
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val[event]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) OnSchedule() []string {
|
||||||
|
schedules := w.OnEvent("schedule")
|
||||||
|
if schedules == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := schedules.(type) {
|
||||||
|
case []any:
|
||||||
|
allSchedules := []string{}
|
||||||
|
for _, v := range val {
|
||||||
|
entry, ok := v.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cron, ok := entry["cron"].(string); ok {
|
||||||
|
allSchedules = append(allSchedules, cron)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allSchedules
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowDispatchInput struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Options []string `yaml:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowDispatch struct {
|
||||||
|
Inputs map[string]WorkflowDispatchInput `yaml:"inputs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch {
|
||||||
|
switch w.RawOn.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var val string
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if val == "workflow_dispatch" {
|
||||||
|
return &WorkflowDispatch{}
|
||||||
|
}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var val []string
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if slices.Contains(val, "workflow_dispatch") {
|
||||||
|
return &WorkflowDispatch{}
|
||||||
|
}
|
||||||
|
case yaml.MappingNode:
|
||||||
|
var val map[string]yaml.Node
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, found := val["workflow_dispatch"]
|
||||||
|
var workflowDispatch WorkflowDispatch
|
||||||
|
if found && decodeNode(n, &workflowDispatch) {
|
||||||
|
return &workflowDispatch
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowCallInput struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
Default string `yaml:"default"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowCallOutput struct {
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowCall struct {
|
||||||
|
Inputs map[string]WorkflowCallInput `yaml:"inputs"`
|
||||||
|
Outputs map[string]WorkflowCallOutput `yaml:"outputs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowCallResult struct {
|
||||||
|
Outputs map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
|
||||||
|
if w.RawOn.Kind != yaml.MappingNode {
|
||||||
|
// The callers expect for "on: workflow_call" and "on: [ workflow_call ]" a non nil return value
|
||||||
|
return &WorkflowCall{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var val map[string]yaml.Node
|
||||||
|
if !decodeNode(w.RawOn, &val) {
|
||||||
|
return &WorkflowCall{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var config WorkflowCall
|
||||||
|
node := val["workflow_call"]
|
||||||
|
if !decodeNode(node, &config) {
|
||||||
|
return &WorkflowCall{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job is the structure of one job in a workflow
|
||||||
|
type Job struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
RawNeeds yaml.Node `yaml:"needs"`
|
||||||
|
RawRunsOn yaml.Node `yaml:"runs-on"`
|
||||||
|
Env yaml.Node `yaml:"env"`
|
||||||
|
If yaml.Node `yaml:"if"`
|
||||||
|
Steps []*Step `yaml:"steps"`
|
||||||
|
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||||
|
RawContinueOnError string `yaml:"continue-on-error"`
|
||||||
|
Services map[string]*ContainerSpec `yaml:"services"`
|
||||||
|
Strategy *Strategy `yaml:"strategy"`
|
||||||
|
RawContainer yaml.Node `yaml:"container"`
|
||||||
|
Defaults Defaults `yaml:"defaults"`
|
||||||
|
Outputs map[string]string `yaml:"outputs"`
|
||||||
|
Uses string `yaml:"uses"`
|
||||||
|
With map[string]any `yaml:"with"`
|
||||||
|
RawSecrets yaml.Node `yaml:"secrets"`
|
||||||
|
RawPermissions yaml.Node `yaml:"permissions"`
|
||||||
|
Result string
|
||||||
|
// Runtime fields set during execution (not from YAML):
|
||||||
|
ContinueOnError bool // true when all failing matrix combinations had continue-on-error=true
|
||||||
|
hasFirmFailure bool // true once any combination failed without continue-on-error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetContinueOnError records whether this combination's failure should not fail the workflow.
|
||||||
|
// Must be called under the job lock. Safe across parallel matrix combinations.
|
||||||
|
func (j *Job) SetContinueOnError(continueOnErr bool) {
|
||||||
|
if continueOnErr {
|
||||||
|
if !j.hasFirmFailure {
|
||||||
|
j.ContinueOnError = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
j.hasFirmFailure = true
|
||||||
|
j.ContinueOnError = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsResult returns the job result as seen by dependent jobs through the
|
||||||
|
// `needs` context. A job that failed but was tolerated via continue-on-error
|
||||||
|
// reports "success" to its dependents, matching GitHub: such a failure must not
|
||||||
|
// block jobs gated on the default `if: success()`, even though the overall
|
||||||
|
// workflow run is still marked as failed.
|
||||||
|
func (j *Job) NeedsResult() string {
|
||||||
|
if j.Result == "failure" && j.ContinueOnError {
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
return j.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy for the job
|
||||||
|
type Strategy struct {
|
||||||
|
FailFast bool
|
||||||
|
MaxParallel int
|
||||||
|
FailFastString string `yaml:"fail-fast"`
|
||||||
|
MaxParallelString string `yaml:"max-parallel"`
|
||||||
|
RawMatrix yaml.Node `yaml:"matrix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default settings that will apply to all steps in the job or workflow
|
||||||
|
type Defaults struct {
|
||||||
|
Run RunDefaults `yaml:"run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults for all run steps in the job or workflow
|
||||||
|
type RunDefaults struct {
|
||||||
|
Shell string `yaml:"shell"`
|
||||||
|
WorkingDirectory string `yaml:"working-directory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxParallel sets default and returns value for `max-parallel`
|
||||||
|
func (s Strategy) GetMaxParallel() int {
|
||||||
|
// MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines`
|
||||||
|
// So I take the liberty to hardcode default limit to 4 and this is because:
|
||||||
|
// 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735
|
||||||
|
// 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45
|
||||||
|
// 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO
|
||||||
|
maxParallel := 4
|
||||||
|
if s.MaxParallelString != "" {
|
||||||
|
var err error
|
||||||
|
if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil {
|
||||||
|
log.Errorf("Failed to parse 'max-parallel' option: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxParallel
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFailFast sets default and returns value for `fail-fast`
|
||||||
|
func (s Strategy) GetFailFast() bool {
|
||||||
|
// FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107
|
||||||
|
failFast := true
|
||||||
|
log.Debug(s.FailFastString)
|
||||||
|
if s.FailFastString != "" {
|
||||||
|
var err error
|
||||||
|
if failFast, err = strconv.ParseBool(s.FailFastString); err != nil {
|
||||||
|
log.Errorf("Failed to parse 'fail-fast' option: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failFast
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *Job) InheritSecrets() bool {
|
||||||
|
if j.RawSecrets.Kind != yaml.ScalarNode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var val string
|
||||||
|
if !decodeNode(j.RawSecrets, &val) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return val == "inherit"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *Job) Secrets() map[string]string {
|
||||||
|
if j.RawSecrets.Kind != yaml.MappingNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var val map[string]string
|
||||||
|
if !decodeNode(j.RawSecrets, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container details for the job
|
||||||
|
func (j *Job) Container() *ContainerSpec {
|
||||||
|
var val *ContainerSpec
|
||||||
|
switch j.RawContainer.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
val = new(ContainerSpec)
|
||||||
|
if !decodeNode(j.RawContainer, &val.Image) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case yaml.MappingNode:
|
||||||
|
val = new(ContainerSpec)
|
||||||
|
if !decodeNode(j.RawContainer, val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needs list for Job
|
||||||
|
func (j *Job) Needs() []string {
|
||||||
|
switch j.RawNeeds.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var val string
|
||||||
|
if !decodeNode(j.RawNeeds, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{val}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var val []string
|
||||||
|
if !decodeNode(j.RawNeeds, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunsOn list for Job
|
||||||
|
func (j *Job) RunsOn() []string {
|
||||||
|
return RunsOnFromNode(j.RawRunsOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a
|
||||||
|
// copy of the node (avoiding mutation of the shared Job) before reading the labels.
|
||||||
|
func RunsOnFromNode(rawRunsOn yaml.Node) []string {
|
||||||
|
switch rawRunsOn.Kind {
|
||||||
|
case yaml.MappingNode:
|
||||||
|
var val struct {
|
||||||
|
Group string
|
||||||
|
Labels yaml.Node
|
||||||
|
}
|
||||||
|
|
||||||
|
if !decodeNode(rawRunsOn, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := nodeAsStringSlice(val.Labels)
|
||||||
|
|
||||||
|
if val.Group != "" {
|
||||||
|
labels = append(labels, val.Group)
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels
|
||||||
|
default:
|
||||||
|
return nodeAsStringSlice(rawRunsOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeAsStringSlice(node yaml.Node) []string {
|
||||||
|
switch node.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var val string
|
||||||
|
if !decodeNode(node, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{val}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var val []string
|
||||||
|
if !decodeNode(node, &val) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func environment(yml yaml.Node) map[string]string {
|
||||||
|
env := make(map[string]string)
|
||||||
|
if yml.Kind == yaml.MappingNode {
|
||||||
|
if !decodeNode(yml, &env) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment returns string-based key=value map for a job
|
||||||
|
func (j *Job) Environment() map[string]string {
|
||||||
|
return environment(j.Env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeMatrixValue converts a matrix value to []interface{}.
|
||||||
|
// Arrays pass through unchanged; scalars are wrapped in a single-element array.
|
||||||
|
// Unevaluated template expressions are wrapped as a fallback — proper resolution
|
||||||
|
// happens via EvaluateYamlNode before Matrix() is called. Nested maps are rejected.
|
||||||
|
func normalizeMatrixValue(key string, val any) ([]any, error) {
|
||||||
|
switch t := val.(type) {
|
||||||
|
case []any:
|
||||||
|
// Already an array - use as-is
|
||||||
|
return t, nil
|
||||||
|
case string, int, float64, bool, nil:
|
||||||
|
// Valid scalar types that can appear in YAML
|
||||||
|
// These can be unevaluated template expressions (strings) or literal values
|
||||||
|
return []any{t}, nil
|
||||||
|
case map[string]any:
|
||||||
|
// Nested map indicates misconfiguration - likely user error
|
||||||
|
return nil, fmt.Errorf("matrix key %q has invalid nested object value - expected scalar or array, got map", key)
|
||||||
|
default:
|
||||||
|
// Unknown types might indicate parsing issues
|
||||||
|
log.Warnf("matrix key %q has unexpected type %T, wrapping as single value", key, t)
|
||||||
|
return []any{t}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matrix decodes the RawMatrix YAML node into a map[string][]interface{}.
|
||||||
|
// Scalar values are wrapped into single-element arrays automatically.
|
||||||
|
// Template expressions are resolved by EvaluateYamlNode before this method is
|
||||||
|
// called; if unresolved, the literal string is wrapped as a one-element fallback.
|
||||||
|
func (j *Job) Matrix() (map[string][]any, error) {
|
||||||
|
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
||||||
|
return map[string][]any{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode to flexible map first so that scalar values don't cause a type error.
|
||||||
|
var flexVal map[string]any
|
||||||
|
err := j.Strategy.RawMatrix.Decode(&flexVal)
|
||||||
|
if err != nil {
|
||||||
|
// Fall back to the strict array-only format for backward compatibility.
|
||||||
|
var val map[string][]any
|
||||||
|
if !decodeNode(j.Strategy.RawMatrix, &val) {
|
||||||
|
return map[string][]any{}, nil
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert flexible format to expected format with validation
|
||||||
|
val := make(map[string][]any)
|
||||||
|
for k, v := range flexVal {
|
||||||
|
normalized, err := normalizeMatrixValue(k, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
val[k] = normalized
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatrixes returns the matrix cross product
|
||||||
|
// It skips includes and hard fails excludes for non-existing keys
|
||||||
|
func (j *Job) GetMatrixes() ([]map[string]any, error) {
|
||||||
|
matrixes := make([]map[string]any, 0)
|
||||||
|
if j.Strategy != nil {
|
||||||
|
// Always set these values, even if there's an error later
|
||||||
|
j.Strategy.FailFast = j.Strategy.GetFailFast()
|
||||||
|
j.Strategy.MaxParallel = j.Strategy.GetMaxParallel()
|
||||||
|
|
||||||
|
m, err := j.Matrix()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(m) > 0 {
|
||||||
|
includes := make([]map[string]any, 0)
|
||||||
|
extraIncludes := make([]map[string]any, 0)
|
||||||
|
addInclude := func(raw any) error {
|
||||||
|
include, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("the workflow is not valid. Matrix include %v is not a map of matrix keys to values", raw)
|
||||||
|
}
|
||||||
|
for k := range include {
|
||||||
|
if _, ok := m[k]; ok {
|
||||||
|
includes = append(includes, include)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extraIncludes = append(extraIncludes, include)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, v := range m["include"] {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case []any:
|
||||||
|
for _, i := range t {
|
||||||
|
if err := addInclude(i); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case any:
|
||||||
|
if err := addInclude(t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(m, "include")
|
||||||
|
|
||||||
|
excludes := make([]map[string]any, 0)
|
||||||
|
for _, e := range m["exclude"] {
|
||||||
|
exclude, ok := e.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude %v is not a map of matrix keys to values", e)
|
||||||
|
}
|
||||||
|
for k := range exclude {
|
||||||
|
if _, ok := m[k]; ok {
|
||||||
|
excludes = append(excludes, exclude)
|
||||||
|
} else {
|
||||||
|
// We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include
|
||||||
|
return nil, fmt.Errorf("the workflow is not valid. Matrix exclude key %q does not match any key within the matrix", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(m, "exclude")
|
||||||
|
|
||||||
|
matrixProduct := common.CartesianProduct(m)
|
||||||
|
MATRIX:
|
||||||
|
for _, matrix := range matrixProduct {
|
||||||
|
for _, exclude := range excludes {
|
||||||
|
if commonKeysMatch(matrix, exclude) {
|
||||||
|
log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude)
|
||||||
|
continue MATRIX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matrixes = append(matrixes, matrix)
|
||||||
|
}
|
||||||
|
for _, include := range includes {
|
||||||
|
matched := false
|
||||||
|
for _, matrix := range matrixes {
|
||||||
|
if commonKeysMatch2(matrix, include, m) {
|
||||||
|
matched = true
|
||||||
|
log.Debugf("Adding include values '%v' to existing entry", include)
|
||||||
|
maps.Copy(matrix, include)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
extraIncludes = append(extraIncludes, include)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, include := range extraIncludes {
|
||||||
|
log.Debugf("Adding include '%v'", include)
|
||||||
|
matrixes = append(matrixes, include)
|
||||||
|
}
|
||||||
|
if len(matrixes) == 0 {
|
||||||
|
matrixes = append(matrixes, make(map[string]any))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
matrixes = append(matrixes, make(map[string]any))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
matrixes = append(matrixes, make(map[string]any))
|
||||||
|
log.Debugf("Empty Strategy, matrixes=%v", matrixes)
|
||||||
|
}
|
||||||
|
return matrixes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonKeysMatch(a, b map[string]any) bool {
|
||||||
|
for aKey, aVal := range a {
|
||||||
|
if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonKeysMatch2(a, b map[string]any, m map[string][]any) bool {
|
||||||
|
for aKey, aVal := range a {
|
||||||
|
_, useKey := m[aKey]
|
||||||
|
if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobType describes what type of job we are about to run
|
||||||
|
type JobType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// JobTypeDefault is all jobs that have a `run` attribute
|
||||||
|
JobTypeDefault JobType = iota
|
||||||
|
|
||||||
|
// JobTypeReusableWorkflowLocal is all jobs that have a `uses` that is a local workflow in the .github/workflows directory
|
||||||
|
JobTypeReusableWorkflowLocal
|
||||||
|
|
||||||
|
// JobTypeReusableWorkflowRemote is all jobs that have a `uses` that references a workflow file in a github repo
|
||||||
|
JobTypeReusableWorkflowRemote
|
||||||
|
|
||||||
|
// JobTypeInvalid represents a job which is not configured correctly
|
||||||
|
JobTypeInvalid
|
||||||
|
)
|
||||||
|
|
||||||
|
func (j JobType) String() string {
|
||||||
|
switch j {
|
||||||
|
case JobTypeDefault:
|
||||||
|
return "default"
|
||||||
|
case JobTypeReusableWorkflowLocal:
|
||||||
|
return "local-reusable-workflow"
|
||||||
|
case JobTypeReusableWorkflowRemote:
|
||||||
|
return "remote-reusable-workflow"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type returns the type of the job
|
||||||
|
func (j *Job) Type() (JobType, error) {
|
||||||
|
isReusable := j.Uses != ""
|
||||||
|
|
||||||
|
if isReusable {
|
||||||
|
isYaml, _ := regexp.MatchString(`\.(ya?ml)(?:$|@)`, j.Uses)
|
||||||
|
|
||||||
|
if isYaml {
|
||||||
|
isLocalPath := strings.HasPrefix(j.Uses, "./")
|
||||||
|
isRemotePath, _ := regexp.MatchString(`^[^.](.+?/){2,}.+\.ya?ml@`, j.Uses)
|
||||||
|
hasVersion, _ := regexp.MatchString(`\.ya?ml@`, j.Uses)
|
||||||
|
|
||||||
|
if isLocalPath {
|
||||||
|
return JobTypeReusableWorkflowLocal, nil
|
||||||
|
} else if isRemotePath && hasVersion {
|
||||||
|
return JobTypeReusableWorkflowRemote, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JobTypeInvalid, fmt.Errorf("`uses` key references invalid workflow path '%s'. Must start with './' if it's a local workflow, or must start with '<org>/<repo>/' and include an '@' if it's a remote workflow", j.Uses)
|
||||||
|
}
|
||||||
|
|
||||||
|
return JobTypeDefault, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerSpec is the specification of the container to use for the job
|
||||||
|
type ContainerSpec struct {
|
||||||
|
Image string `yaml:"image"`
|
||||||
|
Env map[string]string `yaml:"env"`
|
||||||
|
Ports []string `yaml:"ports"`
|
||||||
|
Volumes []string `yaml:"volumes"`
|
||||||
|
Options string `yaml:"options"`
|
||||||
|
Credentials map[string]string `yaml:"credentials"`
|
||||||
|
Entrypoint string
|
||||||
|
Args string
|
||||||
|
Name string
|
||||||
|
Reuse bool
|
||||||
|
|
||||||
|
// Gitea specific
|
||||||
|
Cmd []string `yaml:"cmd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is the structure of one step in a job
|
||||||
|
type Step struct {
|
||||||
|
Number int `yaml:"-"`
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
If yaml.Node `yaml:"if"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Uses string `yaml:"uses"`
|
||||||
|
Run string `yaml:"run"`
|
||||||
|
WorkingDirectory string `yaml:"working-directory"`
|
||||||
|
Shell string `yaml:"shell"`
|
||||||
|
Env yaml.Node `yaml:"env"`
|
||||||
|
With map[string]string `yaml:"with"`
|
||||||
|
RawContinueOnError string `yaml:"continue-on-error"`
|
||||||
|
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across
|
||||||
|
// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env
|
||||||
|
// yaml.Nodes in place, so each job must own its copy.
|
||||||
|
func (s *Step) Clone() *Step {
|
||||||
|
clone := *s
|
||||||
|
clone.If = CloneYamlNode(s.If)
|
||||||
|
clone.Env = CloneYamlNode(s.Env)
|
||||||
|
clone.With = maps.Clone(s.With)
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without
|
||||||
|
// mutating a node shared across parallel jobs.
|
||||||
|
func CloneYamlNode(n yaml.Node) yaml.Node {
|
||||||
|
clone := n
|
||||||
|
if n.Content != nil {
|
||||||
|
clone.Content = make([]*yaml.Node, len(n.Content))
|
||||||
|
for i, child := range n.Content {
|
||||||
|
if child != nil {
|
||||||
|
childClone := CloneYamlNode(*child)
|
||||||
|
clone.Content[i] = &childClone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// String gets the name of step
|
||||||
|
func (s *Step) String() string {
|
||||||
|
if s.Name != "" {
|
||||||
|
return s.Name
|
||||||
|
} else if s.Uses != "" {
|
||||||
|
return s.Uses
|
||||||
|
} else if s.Run != "" {
|
||||||
|
return s.Run
|
||||||
|
}
|
||||||
|
return s.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment returns string-based key=value map for a step
|
||||||
|
func (s *Step) Environment() map[string]string {
|
||||||
|
return environment(s.Env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnv gets the env for a step
|
||||||
|
func (s *Step) GetEnv() map[string]string {
|
||||||
|
env := s.Environment()
|
||||||
|
|
||||||
|
for k, v := range s.With {
|
||||||
|
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(k), "_")
|
||||||
|
envKey = "INPUT_" + strings.ToUpper(envKey)
|
||||||
|
env[envKey] = v
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellCommand returns the command for the shell
|
||||||
|
func (s *Step) ShellCommand() string {
|
||||||
|
var shellCommand string
|
||||||
|
|
||||||
|
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17
|
||||||
|
switch s.Shell {
|
||||||
|
case "", "bash":
|
||||||
|
shellCommand = "bash --noprofile --norc -e -o pipefail {0}"
|
||||||
|
case "pwsh":
|
||||||
|
shellCommand = "pwsh -command . '{0}'"
|
||||||
|
case "python":
|
||||||
|
shellCommand = "python {0}"
|
||||||
|
case "sh":
|
||||||
|
shellCommand = "sh -e {0}"
|
||||||
|
case "cmd":
|
||||||
|
shellCommand = "cmd /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\""
|
||||||
|
case "powershell":
|
||||||
|
shellCommand = "powershell -command . '{0}'"
|
||||||
|
default:
|
||||||
|
shellCommand = s.Shell
|
||||||
|
}
|
||||||
|
return shellCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepType describes what type of step we are about to run
|
||||||
|
type StepType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StepTypeRun is all steps that have a `run` attribute
|
||||||
|
StepTypeRun StepType = iota
|
||||||
|
|
||||||
|
// StepTypeUsesDockerURL is all steps that have a `uses` that is of the form `docker://...`
|
||||||
|
StepTypeUsesDockerURL
|
||||||
|
|
||||||
|
// StepTypeUsesActionLocal is all steps that have a `uses` that is a local action in a subdirectory
|
||||||
|
StepTypeUsesActionLocal
|
||||||
|
|
||||||
|
// StepTypeUsesActionRemote is all steps that have a `uses` that is a reference to a github repo
|
||||||
|
StepTypeUsesActionRemote
|
||||||
|
|
||||||
|
// StepTypeReusableWorkflowLocal is all steps that have a `uses` that is a local workflow in the .github/workflows directory
|
||||||
|
StepTypeReusableWorkflowLocal
|
||||||
|
|
||||||
|
// StepTypeReusableWorkflowRemote is all steps that have a `uses` that references a workflow file in a github repo
|
||||||
|
StepTypeReusableWorkflowRemote
|
||||||
|
|
||||||
|
// StepTypeInvalid is for steps that have invalid step action
|
||||||
|
StepTypeInvalid
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s StepType) String() string {
|
||||||
|
switch s {
|
||||||
|
case StepTypeInvalid:
|
||||||
|
return "invalid"
|
||||||
|
case StepTypeRun:
|
||||||
|
return "run"
|
||||||
|
case StepTypeUsesActionLocal:
|
||||||
|
return "local-action"
|
||||||
|
case StepTypeUsesActionRemote:
|
||||||
|
return "remote-action"
|
||||||
|
case StepTypeUsesDockerURL:
|
||||||
|
return "docker"
|
||||||
|
case StepTypeReusableWorkflowLocal:
|
||||||
|
return "local-reusable-workflow"
|
||||||
|
case StepTypeReusableWorkflowRemote:
|
||||||
|
return "remote-reusable-workflow"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type returns the type of the step
|
||||||
|
func (s *Step) Type() StepType {
|
||||||
|
if s.Run == "" && s.Uses == "" {
|
||||||
|
return StepTypeInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Run != "" {
|
||||||
|
if s.Uses != "" {
|
||||||
|
return StepTypeInvalid
|
||||||
|
}
|
||||||
|
return StepTypeRun
|
||||||
|
} else if strings.HasPrefix(s.Uses, "docker://") {
|
||||||
|
return StepTypeUsesDockerURL
|
||||||
|
} else if strings.HasPrefix(s.Uses, "./.github/workflows") && (strings.HasSuffix(s.Uses, ".yml") || strings.HasSuffix(s.Uses, ".yaml")) {
|
||||||
|
return StepTypeReusableWorkflowLocal
|
||||||
|
} else if !strings.HasPrefix(s.Uses, "./") && strings.Contains(s.Uses, ".github/workflows") && (strings.Contains(s.Uses, ".yml@") || strings.Contains(s.Uses, ".yaml@")) {
|
||||||
|
return StepTypeReusableWorkflowRemote
|
||||||
|
} else if strings.HasPrefix(s.Uses, "./") {
|
||||||
|
return StepTypeUsesActionLocal
|
||||||
|
}
|
||||||
|
return StepTypeUsesActionRemote // `$/` self-repository refs land here and resolve in prepareActionExecutor
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsesHash returns a hash of the uses string.
|
||||||
|
// For Gitea.
|
||||||
|
func (s *Step) UsesHash() string {
|
||||||
|
return UsesHash(s.Uses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsesHash returns a hash of a `uses:` value.
|
||||||
|
// For Gitea.
|
||||||
|
func UsesHash(uses string) string {
|
||||||
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(uses)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadWorkflow returns a list of jobs for a given workflow file reader
|
||||||
|
func ReadWorkflow(in io.Reader) (*Workflow, error) {
|
||||||
|
w := new(Workflow)
|
||||||
|
err := yaml.NewDecoder(in).Decode(w)
|
||||||
|
return w, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJob will get a job by name in the workflow
|
||||||
|
func (w *Workflow) GetJob(jobID string) *Job {
|
||||||
|
for id, j := range w.Jobs {
|
||||||
|
if jobID == id {
|
||||||
|
if j.Name == "" {
|
||||||
|
j.Name = id
|
||||||
|
}
|
||||||
|
if j.If.Value == "" {
|
||||||
|
j.If.Value = "success()"
|
||||||
|
}
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobIDs will get all the job names in the workflow
|
||||||
|
func (w *Workflow) GetJobIDs() []string {
|
||||||
|
ids := make([]string, 0)
|
||||||
|
for id := range w.Jobs {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
var OnDecodeNodeError = func(node yaml.Node, out any, err error) {
|
||||||
|
log.Fatalf("Failed to decode node %v into %T: %v", node, out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeNode(node yaml.Node, out any) bool {
|
||||||
|
if err := node.Decode(out); err != nil {
|
||||||
|
if OnDecodeNodeError != nil {
|
||||||
|
OnDecodeNodeError(node, out, err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
// RawConcurrency represents a workflow concurrency or a job concurrency with uninterpolated options
|
||||||
|
type RawConcurrency struct {
|
||||||
|
Group string `yaml:"group,omitempty"`
|
||||||
|
CancelInProgress string `yaml:"cancel-in-progress,omitempty"`
|
||||||
|
RawExpression string `yaml:"-,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type objectConcurrency RawConcurrency
|
||||||
|
|
||||||
|
func (r *RawConcurrency) UnmarshalYAML(n *yaml.Node) error {
|
||||||
|
if err := n.Decode(&r.RawExpression); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n.Decode((*objectConcurrency)(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RawConcurrency) MarshalYAML() (any, error) {
|
||||||
|
if r.RawExpression != "" {
|
||||||
|
return r.RawExpression, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (*objectConcurrency)(r), nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+81
-29
@@ -23,8 +23,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,9 +124,21 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath
|
|||||||
defer closer.Close()
|
defer closer.Close()
|
||||||
|
|
||||||
action, err := model.ReadAction(reader)
|
action, err := model.ReadAction(reader)
|
||||||
|
// For Gitea, reduce log noise
|
||||||
|
// logger.Debugf("Read action %v from '%s'", action, "Unknown")
|
||||||
return action, err
|
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 {
|
func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actionPath, containerActionDir string) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
@@ -136,19 +148,24 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
containerActionDirCopy := strings.TrimSuffix(containerActionDir, actionPath)
|
var containerActionDirCopy string
|
||||||
|
containerActionDirCopy = strings.TrimSuffix(containerActionDir, actionPath)
|
||||||
logger.Debug(containerActionDirCopy)
|
logger.Debug(containerActionDirCopy)
|
||||||
|
|
||||||
if !strings.HasSuffix(containerActionDirCopy, `/`) {
|
if !strings.HasSuffix(containerActionDirCopy, `/`) {
|
||||||
containerActionDirCopy += `/`
|
containerActionDirCopy += `/`
|
||||||
}
|
}
|
||||||
|
|
||||||
defer git.AcquireCloneLock(actionDir)()
|
if rc.Config != nil && rc.Config.ActionCache != nil {
|
||||||
|
ta, err := cachedActionTar(ctx, step, stepModel.Uses, "")
|
||||||
if !rc.Config.NoActionPatch {
|
if err != nil {
|
||||||
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
|
return err
|
||||||
patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))
|
|
||||||
}
|
}
|
||||||
|
defer ta.Close()
|
||||||
|
return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer git.AcquireCloneLock(actionDir)()
|
||||||
|
|
||||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -169,10 +186,13 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
}
|
}
|
||||||
|
|
||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
|
// For Gitea, reduce log noise
|
||||||
|
// logger.Debugf("About to run action %v", action)
|
||||||
|
|
||||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
err := setupActionEnv(ctx, step, remoteAction)
|
||||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
if err != nil {
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
actionLocation := path.Join(actionDir, actionPath)
|
actionLocation := path.Join(actionDir, actionPath)
|
||||||
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||||
@@ -190,7 +210,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
|
|
||||||
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||||
case x.IsDocker():
|
case x.IsDocker():
|
||||||
location := actionLocation
|
location := actionLocation
|
||||||
if remoteAction == nil {
|
if remoteAction == nil {
|
||||||
@@ -215,11 +235,11 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
|
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir),
|
rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
|
||||||
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""),
|
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
|
||||||
)(ctx)
|
)(ctx)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("the runs.using key must be one of: %v, got %s", []string{
|
return fmt.Errorf("The runs.using key must be one of: %v, got %s", []string{
|
||||||
model.ActionRunsUsingDocker,
|
model.ActionRunsUsingDocker,
|
||||||
model.ActionRunsUsingNode12,
|
model.ActionRunsUsingNode12,
|
||||||
model.ActionRunsUsingNode16,
|
model.ActionRunsUsingNode16,
|
||||||
@@ -232,6 +252,20 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupActionEnv(ctx context.Context, step actionStep, _ *remoteAction) error {
|
||||||
|
rc := step.getRunContext()
|
||||||
|
|
||||||
|
// A few fields in the environment (e.g. GITHUB_ACTION_REPOSITORY)
|
||||||
|
// are dependent on the action. That means we can complete the
|
||||||
|
// setup only after resolving the whole action model and cloning
|
||||||
|
// the action
|
||||||
|
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
||||||
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
|
populateEnvsFromInput(ctx, step.getEnv(), step.getActionModel(), rc)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// https://github.com/nektos/act/issues/228#issuecomment-629709055
|
// https://github.com/nektos/act/issues/228#issuecomment-629709055
|
||||||
// files in .gitignore are not copied in a Docker container
|
// files in .gitignore are not copied in a Docker container
|
||||||
// this causes issues with actions that ignore other important resources
|
// this causes issues with actions that ignore other important resources
|
||||||
@@ -325,6 +359,12 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer buildContext.Close()
|
defer buildContext.Close()
|
||||||
|
} else if rc.Config.ActionCache != nil {
|
||||||
|
buildContext, err = cachedActionTar(ctx, step, actionName, contextDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer buildContext.Close()
|
||||||
}
|
}
|
||||||
prepImage = ContainerNewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
|
prepImage = ContainerNewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
|
||||||
ContextDir: contextDir,
|
ContextDir: contextDir,
|
||||||
@@ -359,19 +399,21 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
prepImage,
|
prepImage,
|
||||||
stepContainer.Pull(forcePull),
|
stepContainer.Pull(forcePull),
|
||||||
stepContainer.Remove(),
|
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
||||||
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
stepContainer.Start(true),
|
stepContainer.Start(true),
|
||||||
|
).Finally(
|
||||||
|
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers && !rc.Config.AutoRemove),
|
||||||
).Finally(stepContainer.Close())(ctx)
|
).Finally(stepContainer.Close())(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dockerEntrypoint returns the entrypoint the action's image runs with for the given
|
// dockerEntrypoint returns the entrypoint the action's image runs with for the given
|
||||||
// stage. Only the main stage honours the `entrypoint` input.
|
// stage. Only the main stage honours the `entrypoint` input.
|
||||||
func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEvaluator, stage stepStage) ([]string, error) {
|
func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvaluator, stage stepStage) ([]string, error) {
|
||||||
runs := step.getActionModel().Runs
|
runs := step.getActionModel().Runs
|
||||||
|
|
||||||
var entrypoint string
|
var entrypoint string
|
||||||
@@ -422,9 +464,18 @@ func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container {
|
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string) container.Container {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
stepModel := step.getStepModel()
|
||||||
|
rawLogger := common.Logger(ctx).WithField("raw_output", true)
|
||||||
|
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||||
|
if rc.Config.LogOutput {
|
||||||
|
rawLogger.Infof("%s", s)
|
||||||
|
} else {
|
||||||
|
rawLogger.Debugf("%s", s)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
for k, v := range *step.getEnv() {
|
for k, v := range *step.getEnv() {
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
@@ -437,12 +488,12 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
if rc.IsHostEnv(ctx) {
|
if rc.IsHostEnv(ctx) {
|
||||||
networkMode = "default"
|
networkMode = "default"
|
||||||
}
|
}
|
||||||
return ContainerNewContainer(&container.NewContainerInput{
|
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
Entrypoint: entrypoint,
|
Entrypoint: entrypoint,
|
||||||
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||||
Image: image,
|
Image: image,
|
||||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.getStepModel().ID),
|
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
|
||||||
Env: envList,
|
Env: envList,
|
||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
NetworkMode: networkMode,
|
NetworkMode: networkMode,
|
||||||
@@ -452,11 +503,12 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
Privileged: rc.Config.Privileged,
|
Privileged: rc.Config.Privileged,
|
||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
RunnerOptions: runnerOptions,
|
Options: rc.Config.ContainerOptions,
|
||||||
AutoRemove: true,
|
AutoRemove: rc.Config.AutoRemove,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.validVolumes(),
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
})
|
||||||
|
return stepContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
||||||
@@ -591,7 +643,7 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
|
|
||||||
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||||
|
|
||||||
case x.IsDocker():
|
case x.IsDocker():
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
// defaults in pre steps were missing, however provided inputs are available
|
||||||
@@ -624,8 +676,8 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
|
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir),
|
rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
|
||||||
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""),
|
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
|
||||||
)(ctx)
|
)(ctx)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
@@ -692,7 +744,7 @@ func runPostStep(step actionStep) common.Executor {
|
|||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
|
|
||||||
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||||
|
|
||||||
case x.IsDocker():
|
case x.IsDocker():
|
||||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
@@ -718,8 +770,8 @@ func runPostStep(step actionStep) common.Executor {
|
|||||||
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
|
execArgs := []string{filepath.Join(containerActionDir, execFileName)}
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir),
|
rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
|
||||||
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""),
|
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
|
||||||
)(ctx)
|
)(ctx)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
config "github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionCache interface {
|
||||||
|
Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error)
|
||||||
|
GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoGitActionCache struct {
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c GoGitActionCache) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
|
||||||
|
gitPath := path.Join(c.Path, safeFilename(cacheDir)+".git")
|
||||||
|
gogitrepo, err := git.PlainInit(gitPath, true)
|
||||||
|
if errors.Is(err, git.ErrRepositoryAlreadyExists) {
|
||||||
|
gogitrepo, err = git.PlainOpen(gitPath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpBranch := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(tmpBranch); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
branchName := hex.EncodeToString(tmpBranch)
|
||||||
|
|
||||||
|
var auth transport.AuthMethod
|
||||||
|
if token != "" {
|
||||||
|
auth = &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remote, err := gogitrepo.CreateRemoteAnonymous(&config.RemoteConfig{
|
||||||
|
Name: "anonymous",
|
||||||
|
URLs: []string{
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = gogitrepo.DeleteBranch(branchName)
|
||||||
|
}()
|
||||||
|
if err := remote.FetchContext(ctx, &git.FetchOptions{
|
||||||
|
RefSpecs: []config.RefSpec{
|
||||||
|
config.RefSpec(ref + ":" + branchName),
|
||||||
|
},
|
||||||
|
Auth: auth,
|
||||||
|
Force: true,
|
||||||
|
}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hash, err := gogitrepo.ResolveRevision(plumbing.Revision(branchName))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hash.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c GoGitActionCache) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
|
||||||
|
gitPath := path.Join(c.Path, safeFilename(cacheDir)+".git")
|
||||||
|
gogitrepo, err := git.PlainOpen(gitPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
commit, err := gogitrepo.CommitObject(plumbing.NewHash(sha))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
files, err := commit.Files()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rpipe, wpipe := io.Pipe()
|
||||||
|
// Interrupt io.Copy using ctx
|
||||||
|
ch := make(chan int, 1)
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
wpipe.CloseWithError(ctx.Err())
|
||||||
|
case <-ch:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wpipe.Close()
|
||||||
|
defer close(ch)
|
||||||
|
tw := tar.NewWriter(wpipe)
|
||||||
|
cleanIncludePrefix := path.Clean(includePrefix)
|
||||||
|
wpipe.CloseWithError(files.ForEach(func(f *object.File) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := f.Name
|
||||||
|
if strings.HasPrefix(name, cleanIncludePrefix+"/") {
|
||||||
|
name = name[len(cleanIncludePrefix)+1:]
|
||||||
|
} else if cleanIncludePrefix != "." && name != cleanIncludePrefix {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmode, err := f.Mode.ToOSFileMode()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fmode&fs.ModeSymlink == fs.ModeSymlink {
|
||||||
|
content, err := f.Contents()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tw.WriteHeader(&tar.Header{
|
||||||
|
Name: name,
|
||||||
|
Mode: int64(fmode),
|
||||||
|
Linkname: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
err = tw.WriteHeader(&tar.Header{
|
||||||
|
Name: name,
|
||||||
|
Mode: int64(fmode),
|
||||||
|
Size: f.Size,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader, err := f.Reader()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(tw, reader)
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
}()
|
||||||
|
return rpipe, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
cacheDir := "local/act-test-actions"
|
||||||
|
refs := []struct {
|
||||||
|
Name string
|
||||||
|
Ref string
|
||||||
|
}{
|
||||||
|
{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, 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, 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
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
// G110: Potential DoS vulnerability via decompression bomb (gosec)
|
||||||
|
_, err = io.Copy(buf, mytar)
|
||||||
|
a.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
str := buf.String()
|
||||||
|
a.NotEmpty(str)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
||||||
@@ -55,7 +54,7 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
|||||||
env := evaluateCompositeInputAndEnv(ctx, parent, step)
|
env := evaluateCompositeInputAndEnv(ctx, parent, step)
|
||||||
|
|
||||||
// run with the global config but without secrets
|
// run with the global config but without secrets
|
||||||
configCopy := *parent.Config
|
configCopy := *(parent.Config)
|
||||||
configCopy.Secrets = nil
|
configCopy.Secrets = nil
|
||||||
|
|
||||||
// create a run context for the composite action to run in
|
// create a run context for the composite action to run in
|
||||||
@@ -181,7 +180,20 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
|
|||||||
stepPre := rc.newCompositeCommandExecutor(step.pre())
|
stepPre := rc.newCompositeCommandExecutor(step.pre())
|
||||||
preSteps = append(preSteps, newCompositeStepLogExecutor(stepPre, stepID))
|
preSteps = append(preSteps, newCompositeStepLogExecutor(stepPre, stepID))
|
||||||
|
|
||||||
steps = append(steps, newCompositeStepLogExecutor(rc.newCompositeCommandExecutor(step.main()), stepID))
|
steps = append(steps, func(ctx context.Context) error {
|
||||||
|
ctx = WithCompositeStepLogger(ctx, stepID)
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
err := rc.newCompositeCommandExecutor(step.main())(ctx)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("##[error]%s", EscapeCommandData(err.Error()))
|
||||||
|
common.SetJobError(ctx, err)
|
||||||
|
} else if ctx.Err() != nil {
|
||||||
|
logger.Errorf("##[error]%s", EscapeCommandData(ctx.Err().Error()))
|
||||||
|
common.SetJobError(ctx, ctx.Err())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
// run the post executor in reverse order
|
// run the post executor in reverse order
|
||||||
if postExecutor != nil {
|
if postExecutor != nil {
|
||||||
@@ -209,7 +221,19 @@ func (rc *RunContext) newCompositeCommandExecutor(executor common.Executor) comm
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ctx = WithCompositeLogger(ctx, &rc.Masks)
|
ctx = WithCompositeLogger(ctx, &rc.Masks)
|
||||||
|
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
// We need to inject a composite RunContext related command
|
||||||
|
// handler into the current running job container
|
||||||
|
// We need this, to support scoping commands to the composite action
|
||||||
|
// executing.
|
||||||
|
rawLogger := common.Logger(ctx).WithField("raw_output", true)
|
||||||
|
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||||
|
if rc.Config.LogOutput {
|
||||||
|
rawLogger.Infof("%s", s)
|
||||||
|
} else {
|
||||||
|
rawLogger.Debugf("%s", s)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||||
|
|||||||
+70
-20
@@ -16,17 +16,19 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type closerFunc func()
|
type closerMock struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
func (close closerFunc) Close() error {
|
func (m *closerMock) Close() error {
|
||||||
close()
|
m.Called()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,15 +39,6 @@ runs:
|
|||||||
using: 'node16'
|
using: 'node16'
|
||||||
main: 'main.js'
|
main: 'main.js'
|
||||||
`, "\t", " ")
|
`, "\t", " ")
|
||||||
yamlAction := &model.Action{
|
|
||||||
Name: "name",
|
|
||||||
Runs: model.ActionRuns{
|
|
||||||
Using: "node16",
|
|
||||||
Main: "main.js",
|
|
||||||
PreIf: "always()",
|
|
||||||
PostIf: "always()",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -59,14 +52,30 @@ runs:
|
|||||||
step: &model.Step{},
|
step: &model.Step{},
|
||||||
filename: "action.yml",
|
filename: "action.yml",
|
||||||
fileContent: yaml,
|
fileContent: yaml,
|
||||||
expected: yamlAction,
|
expected: &model.Action{
|
||||||
|
Name: "name",
|
||||||
|
Runs: model.ActionRuns{
|
||||||
|
Using: "node16",
|
||||||
|
Main: "main.js",
|
||||||
|
PreIf: "always()",
|
||||||
|
PostIf: "always()",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "readActionYaml",
|
name: "readActionYaml",
|
||||||
step: &model.Step{},
|
step: &model.Step{},
|
||||||
filename: "action.yaml",
|
filename: "action.yaml",
|
||||||
fileContent: yaml,
|
fileContent: yaml,
|
||||||
expected: yamlAction,
|
expected: &model.Action{
|
||||||
|
Name: "name",
|
||||||
|
Runs: model.ActionRuns{
|
||||||
|
Using: "node16",
|
||||||
|
Main: "main.js",
|
||||||
|
PreIf: "always()",
|
||||||
|
PostIf: "always()",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "readDockerfile",
|
name: "readDockerfile",
|
||||||
@@ -112,14 +121,14 @@ runs:
|
|||||||
|
|
||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
closed := false
|
closerMock := &closerMock{}
|
||||||
|
|
||||||
readFile := func(filename string) (io.Reader, io.Closer, error) {
|
readFile := func(filename string) (io.Reader, io.Closer, error) {
|
||||||
if tt.filename != filename {
|
if tt.filename != filename {
|
||||||
return nil, nil, fs.ErrNotExist
|
return nil, nil, fs.ErrNotExist
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.NewReader(tt.fileContent), closerFunc(func() { closed = true }), nil
|
return strings.NewReader(tt.fileContent), closerMock, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFile := func(filename string, data []byte, perm fs.FileMode) error {
|
writeFile := func(filename string, data []byte, perm fs.FileMode) error {
|
||||||
@@ -128,16 +137,58 @@ runs:
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tt.filename != "" {
|
||||||
|
closerMock.On("Close")
|
||||||
|
}
|
||||||
|
|
||||||
action, err := readActionImpl(context.Background(), tt.step, "actionDir", "actionPath", readFile, writeFile)
|
action, err := readActionImpl(context.Background(), tt.step, "actionDir", "actionPath", readFile, writeFile)
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, tt.expected, action)
|
assert.Equal(t, tt.expected, action)
|
||||||
|
|
||||||
assert.Equal(t, tt.filename != "", closed)
|
closerMock.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestActionRunner(t *testing.T) {
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -286,12 +337,11 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
|||||||
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
||||||
step.On("getEnv").Return(&env)
|
step.On("getEnv").Return(&env)
|
||||||
|
|
||||||
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
|
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil)
|
||||||
|
|
||||||
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
||||||
assert.Empty(t, captured.Username)
|
assert.Empty(t, captured.Username)
|
||||||
assert.Empty(t, captured.Password)
|
assert.Empty(t, captured.Password)
|
||||||
assert.True(t, captured.AutoRemove)
|
|
||||||
step.AssertExpectations(t)
|
step.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
@@ -107,14 +107,17 @@ func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
|
|||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := newControllableDeadlineContext(context.Background())
|
// A short deadline that we let elapse between steps, so no step records the error itself.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
var ran []string
|
var ran []string
|
||||||
var laterStepCtxErr error
|
var laterStepCtxErr error
|
||||||
steps := []common.Executor{
|
steps := []common.Executor{
|
||||||
func(context.Context) error {
|
func(c context.Context) error {
|
||||||
ran = append(ran, "step1")
|
ran = append(ran, "step1")
|
||||||
ctx.expire()
|
// Block until the job deadline elapses, then return cleanly: the interrupt lands in the loop's between-steps check, not inside a step.
|
||||||
|
<-c.Done()
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
func(c context.Context) error {
|
func(c context.Context) error {
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string,
|
|||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
stepID := rc.CurrentStep
|
stepID := rc.CurrentStep
|
||||||
outputName := kvPairs["name"]
|
outputName := kvPairs["name"]
|
||||||
|
if outputMapping, ok := rc.OutputMappings[MappableOutput{StepID: stepID, OutputName: outputName}]; ok {
|
||||||
|
stepID = outputMapping.StepID
|
||||||
|
outputName = outputMapping.OutputName
|
||||||
|
}
|
||||||
|
|
||||||
result, ok := rc.StepResults[stepID]
|
result, ok := rc.StepResults[stepID]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import (
|
|||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
)
|
)
|
||||||
|
|
||||||
var noopExecutor = func(context.Context) error { return nil }
|
|
||||||
|
|
||||||
type containerMock struct {
|
type containerMock struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
container.Container
|
container.Container
|
||||||
@@ -52,6 +50,11 @@ func (cm *containerMock) UpdateFromEnv(srcPath string, env *map[string]string) c
|
|||||||
return args.Get(0).(func(context.Context) error)
|
return args.Get(0).(func(context.Context) error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cm *containerMock) UpdateFromImageEnv(env *map[string]string) common.Executor {
|
||||||
|
args := cm.Called(env)
|
||||||
|
return args.Get(0).(func(context.Context) error)
|
||||||
|
}
|
||||||
|
|
||||||
func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) common.Executor {
|
func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) common.Executor {
|
||||||
args := cm.Called(destPath, files)
|
args := cm.Called(destPath, files)
|
||||||
return args.Get(0).(func(context.Context) error)
|
return args.Get(0).(func(context.Context) error)
|
||||||
|
|||||||
+274
-24
@@ -7,6 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"path"
|
"path"
|
||||||
@@ -17,21 +18,29 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/expreval"
|
"github.com/rhysd/actionlint"
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ExpressionEvaluator is the interface for evaluating expressions
|
||||||
|
type ExpressionEvaluator interface {
|
||||||
|
evaluate(context.Context, string, exprparser.DefaultStatusCheck) (any, error)
|
||||||
|
interpolate(context.Context, string) (string, error)
|
||||||
|
EvaluateYamlNode(context.Context, *yaml.Node) error
|
||||||
|
Interpolate(context.Context, string) string
|
||||||
|
}
|
||||||
|
|
||||||
// NewExpressionEvaluator creates a new evaluator
|
// NewExpressionEvaluator creates a new evaluator
|
||||||
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) *ExpressionEvaluator {
|
func (rc *RunContext) NewExpressionEvaluator(ctx context.Context) ExpressionEvaluator {
|
||||||
return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
|
return rc.NewExpressionEvaluatorWithEnv(ctx, rc.GetEnv())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map[string]string) *ExpressionEvaluator {
|
func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map[string]string) ExpressionEvaluator {
|
||||||
var workflowCallResult map[string]*model.WorkflowCallResult
|
var workflowCallResult map[string]*model.WorkflowCallResult
|
||||||
|
|
||||||
// todo: cleanup EvaluationEnvironment creation
|
// todo: cleanup EvaluationEnvironment creation
|
||||||
@@ -90,7 +99,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
HashFiles: getHashFilesFunction(ctx, rc),
|
HashFiles: getHashFilesFunction(ctx, rc),
|
||||||
}
|
}
|
||||||
ee.Runner = rc.getRunnerContext(ctx)
|
ee.Runner = rc.getRunnerContext(ctx)
|
||||||
return &expressionEvaluator{
|
return expressionEvaluator{
|
||||||
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
||||||
Run: rc.Run,
|
Run: rc.Run,
|
||||||
WorkingDir: rc.Config.Workdir,
|
WorkingDir: rc.Config.Workdir,
|
||||||
@@ -103,7 +112,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
var hashfiles string
|
var hashfiles string
|
||||||
|
|
||||||
// NewStepExpressionEvaluator creates a new evaluator
|
// NewStepExpressionEvaluator creates a new evaluator
|
||||||
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) ExpressionEvaluator {
|
||||||
// todo: cleanup EvaluationEnvironment creation
|
// todo: cleanup EvaluationEnvironment creation
|
||||||
job := rc.Run.Job()
|
job := rc.Run.Job()
|
||||||
strategy := make(map[string]any)
|
strategy := make(map[string]any)
|
||||||
@@ -142,7 +151,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step)
|
|||||||
HashFiles: getHashFilesFunction(ctx, rc),
|
HashFiles: getHashFilesFunction(ctx, rc),
|
||||||
}
|
}
|
||||||
ee.Runner = rc.getRunnerContext(ctx)
|
ee.Runner = rc.getRunnerContext(ctx)
|
||||||
return &expressionEvaluator{
|
return expressionEvaluator{
|
||||||
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
interpreter: exprparser.NewInterpeter(ee, exprparser.Config{
|
||||||
Run: rc.Run,
|
Run: rc.Run,
|
||||||
WorkingDir: rc.Config.Workdir,
|
WorkingDir: rc.Config.Workdir,
|
||||||
@@ -170,7 +179,7 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.
|
|||||||
followSymlink = true
|
followSymlink = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("invalid glob option %s, available option: '--follow-symbolic-links'", s)
|
return "", fmt.Errorf("Invalid glob option %s, available option: '--follow-symbolic-links'", s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
patterns = append(patterns, s)
|
patterns = append(patterns, s)
|
||||||
@@ -188,7 +197,7 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.
|
|||||||
Mode: 0o644,
|
Mode: 0o644,
|
||||||
Body: hashfiles,
|
Body: hashfiles,
|
||||||
}).
|
}).
|
||||||
Then(rc.JobContainer.Exec([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)},
|
Then(rc.execJobContainer([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)},
|
||||||
env, "", "")).
|
env, "", "")).
|
||||||
Finally(func(context.Context) error {
|
Finally(func(context.Context) error {
|
||||||
rc.JobContainer.ReplaceLogWriter(stdout, stderr)
|
rc.JobContainer.ReplaceLogWriter(stdout, stderr)
|
||||||
@@ -214,8 +223,6 @@ type expressionEvaluator struct {
|
|||||||
interpreter exprparser.Interpreter
|
interpreter exprparser.Interpreter
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExpressionEvaluator = expressionEvaluator
|
|
||||||
|
|
||||||
func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
logger.Debugf("evaluating expression '%s'", in)
|
logger.Debugf("evaluating expression '%s'", in)
|
||||||
@@ -228,16 +235,137 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt
|
|||||||
return evaluated, err
|
return evaluated, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// shared returns the evaluation layer of the shared library, bound to this context so the
|
func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
||||||
// evaluation of every single expression is still logged and masked here.
|
var in string
|
||||||
func (ee expressionEvaluator) shared(ctx context.Context) expreval.Evaluator {
|
if err := node.Decode(&in); err != nil {
|
||||||
return expreval.New(func(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
return nil, err
|
||||||
return ee.evaluate(ctx, in, defaultStatusCheck)
|
}
|
||||||
})
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
res, err := ee.evaluateScalar(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ret := &yaml.Node{}
|
||||||
|
if err := ret.Encode(res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
||||||
|
var ret *yaml.Node
|
||||||
|
// GitHub has this undocumented feature to merge maps, called insert directive
|
||||||
|
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||||
|
for i := 0; i < len(node.Content)/2; i++ {
|
||||||
|
changed := func() error {
|
||||||
|
if ret == nil {
|
||||||
|
ret = &yaml.Node{}
|
||||||
|
if err := ret.Encode(node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ret.Content = ret.Content[:i*2]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
k := node.Content[i*2]
|
||||||
|
v := node.Content[i*2+1]
|
||||||
|
ev, err := ee.evaluateYamlNodeInternal(ctx, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ev != nil {
|
||||||
|
if err := changed(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ev = v
|
||||||
|
}
|
||||||
|
var sk string
|
||||||
|
// Merge the nested map of the insert directive
|
||||||
|
if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
|
||||||
|
if ev.Kind != yaml.MappingNode {
|
||||||
|
return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind)
|
||||||
|
}
|
||||||
|
if err := changed(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ret.Content = append(ret.Content, ev.Content...)
|
||||||
|
} else {
|
||||||
|
ek, err := ee.evaluateYamlNodeInternal(ctx, k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ek != nil {
|
||||||
|
if err := changed(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ek = k
|
||||||
|
}
|
||||||
|
if ret != nil {
|
||||||
|
ret.Content = append(ret.Content, ek, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
||||||
|
var ret *yaml.Node
|
||||||
|
for i := 0; i < len(node.Content); i++ {
|
||||||
|
v := node.Content[i]
|
||||||
|
// Preserve nested sequences
|
||||||
|
wasseq := v.Kind == yaml.SequenceNode
|
||||||
|
ev, err := ee.evaluateYamlNodeInternal(ctx, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ev != nil {
|
||||||
|
if ret == nil {
|
||||||
|
ret = &yaml.Node{}
|
||||||
|
if err := ret.Encode(node); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ret.Content = ret.Content[:i]
|
||||||
|
}
|
||||||
|
// GitHub has this undocumented feature to merge sequences / arrays
|
||||||
|
// We have a nested sequence via evaluation, merge the arrays
|
||||||
|
if ev.Kind == yaml.SequenceNode && !wasseq {
|
||||||
|
ret.Content = append(ret.Content, ev.Content...)
|
||||||
|
} else {
|
||||||
|
ret.Content = append(ret.Content, ev)
|
||||||
|
}
|
||||||
|
} else if ret != nil {
|
||||||
|
ret.Content = append(ret.Content, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ee expressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node) (*yaml.Node, error) {
|
||||||
|
switch node.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
return ee.evaluateScalarYamlNode(ctx, node)
|
||||||
|
case yaml.MappingNode:
|
||||||
|
return ee.evaluateMappingYamlNode(ctx, node)
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
return ee.evaluateSequenceYamlNode(ctx, node)
|
||||||
|
default:
|
||||||
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error {
|
||||||
return ee.shared(ctx).EvaluateYamlNode(node)
|
ret, err := ee.evaluateYamlNodeInternal(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ret != nil {
|
||||||
|
return ret.Decode(node)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
||||||
@@ -249,16 +377,138 @@ func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
|
||||||
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
||||||
return ee.shared(ctx).Interpolate(in)
|
parts, err := splitSubExpressions(in)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(parts) == 1 && !parts[0].isExpr {
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
out.Grow(len(in))
|
||||||
|
for _, part := range parts {
|
||||||
|
if !part.isExpr {
|
||||||
|
out.WriteString(part.text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
evaluated, err := ee.evaluate(ctx, part.text, exprparser.DefaultStatusCheckNone)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out.WriteString(exprparser.CoerceToString(evaluated))
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
|
||||||
|
func (ee expressionEvaluator) evaluateScalar(ctx context.Context, in string) (any, error) {
|
||||||
|
parts, err := splitSubExpressions(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(parts) == 1 && parts[0].isExpr {
|
||||||
|
return ee.evaluate(ctx, parts[0].text, exprparser.DefaultStatusCheckNone)
|
||||||
|
}
|
||||||
|
return ee.interpolate(ctx, in)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
||||||
// `${{ }}`, while literal text around one makes the whole value a string.
|
// `${{ }}`, while literal text around one makes the whole value a string.
|
||||||
func EvalBool(ctx context.Context, evaluator *expressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
|
||||||
return expreval.New(func(in string, dsc exprparser.DefaultStatusCheck) (any, error) {
|
parts, err := splitSubExpressions(expr)
|
||||||
return evaluator.evaluate(ctx, in, dsc)
|
if err != nil {
|
||||||
}).EvalBool(expr, defaultStatusCheck)
|
return false, err
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
evaluated, err := evaluator.evaluate(ctx, parts[0].text, defaultStatusCheck)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exprparser.IsTruthy(evaluated), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mixed content is a string, so the status check applies to it separately
|
||||||
|
if defaultStatusCheck != exprparser.DefaultStatusCheckNone && !callsStatusFunction(parts) {
|
||||||
|
status, err := evaluator.evaluate(ctx, "", defaultStatusCheck)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !exprparser.IsTruthy(status) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interpolated, err := evaluator.interpolate(ctx, expr)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exprparser.IsTruthy(interpolated), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callsStatusFunction reports whether any part calls a status function. A part that does not parse
|
||||||
|
// counts as one, so the evaluation reports it against the real values.
|
||||||
|
func callsStatusFunction(parts []exprPart) bool {
|
||||||
|
for _, part := range parts {
|
||||||
|
if !part.isExpr {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The lexer needs the closing `}}` that the scanner strips.
|
||||||
|
exprNode, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
|
||||||
|
if err != nil || exprparser.CallsStatusFunction(exprNode) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type exprPart struct {
|
||||||
|
text string
|
||||||
|
isExpr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
|
||||||
|
// complete expression literal.
|
||||||
|
func splitSubExpressions(in string) ([]exprPart, error) {
|
||||||
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||||
|
return []exprPart{{text: in}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
|
||||||
|
for {
|
||||||
|
start := strings.Index(in, "${{")
|
||||||
|
if start < 0 {
|
||||||
|
if in != "" {
|
||||||
|
parts = append(parts, exprPart{text: in})
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
if start > 0 {
|
||||||
|
parts = append(parts, exprPart{text: in[:start]})
|
||||||
|
}
|
||||||
|
rest := in[start+len("${{"):]
|
||||||
|
end := indexExprEnd(rest)
|
||||||
|
if end < 0 {
|
||||||
|
return nil, errors.New("unclosed expression")
|
||||||
|
}
|
||||||
|
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
|
||||||
|
in = rest[end+len("}}"):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
|
||||||
|
// state, so a `}}` inside a string does not end it.
|
||||||
|
func indexExprEnd(in string) int {
|
||||||
|
inString := false
|
||||||
|
for i := range len(in) {
|
||||||
|
switch {
|
||||||
|
case in[i] == '\'':
|
||||||
|
inString = !inString
|
||||||
|
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
@@ -278,6 +279,41 @@ func TestInterpolate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSplitSubExpressions(t *testing.T) {
|
||||||
|
expr := func(text string) exprPart { return exprPart{text: text, isExpr: true} }
|
||||||
|
literal := func(text string) exprPart { return exprPart{text: text} }
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
in string
|
||||||
|
want []exprPart
|
||||||
|
}{
|
||||||
|
{"Hello World", []exprPart{literal("Hello World")}},
|
||||||
|
{"${{ true }}", []exprPart{expr("true")}},
|
||||||
|
{"${{ true }} ${{ false }}", []exprPart{expr("true"), literal(" "), expr("false")}},
|
||||||
|
{"Hello ${{ 'World' }}", []exprPart{literal("Hello "), expr("'World'")}},
|
||||||
|
// a quote toggles string state, so a `}}` inside a string does not end the expression
|
||||||
|
{"${{ '}}' }}", []exprPart{expr("'}}'")}},
|
||||||
|
{"${{ '''}}''' }}", []exprPart{expr("'''}}'''")}},
|
||||||
|
{"${{ '''' }}", []exprPart{expr("''''")}},
|
||||||
|
{`${{ fromJSON('"}}"') }}`, []exprPart{expr(`fromJSON('"}}"')`)}},
|
||||||
|
{`${{ fromJSON('"\"}}\""') }}`, []exprPart{expr(`fromJSON('"\"}}\""')`)}},
|
||||||
|
{`${{ fromJSON('"''}}"') }}`, []exprPart{expr(`fromJSON('"''}}"')`)}},
|
||||||
|
// without a complete literal the value stays text, as GitHub's template reader leaves it
|
||||||
|
{"${{ 1", []exprPart{literal("${{ 1")}},
|
||||||
|
// a malformed part stays one part, so it cannot restructure its neighbours
|
||||||
|
{"${{ 1) && (2 }}", []exprPart{expr("1) && (2")}},
|
||||||
|
} {
|
||||||
|
got, err := splitSubExpressions(tt.in)
|
||||||
|
require.NoError(t, err, tt.in)
|
||||||
|
assert.Equal(t, tt.want, got, tt.in)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, in := range []string{"${{ 'a' }} ${{ b", "${{ 'a }}"} {
|
||||||
|
_, err := splitSubExpressions(in)
|
||||||
|
assert.ErrorContains(t, err, "unclosed expression", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||||
workflows := map[string]string{
|
workflows := map[string]string{
|
||||||
"workflow_call": `
|
"workflow_call": `
|
||||||
@@ -356,25 +392,3 @@ on:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJobNameMasksSecrets(t *testing.T) {
|
|
||||||
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
|
||||||
jobs:
|
|
||||||
a:
|
|
||||||
name: deploy ${{ secrets.A }}
|
|
||||||
b:
|
|
||||||
name: deploy ${{ secrets.B }}
|
|
||||||
`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
|
|
||||||
containerName := func(jobID string) string {
|
|
||||||
rc := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
|
|
||||||
assert.NotContains(t, rc.Name, "s3cr3t")
|
|
||||||
return rc.jobContainerName()
|
|
||||||
}
|
|
||||||
|
|
||||||
a, b := containerName("a"), containerName("b")
|
|
||||||
assert.NotContains(t, a, "s3cr3t") // it reaches the container name, which no log masker covers
|
|
||||||
assert.NotEqual(t, a, b) // masking the name must not collapse two jobs onto one container
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json/v2"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -23,9 +23,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxJobSummaryBytes = 1024 * 1024
|
const maxJobSummaryBytes = 1024 * 1024
|
||||||
@@ -240,16 +239,44 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
var err error
|
var err error
|
||||||
|
// jobError == nil keeps a failed job's container alive for post-mortem debugging when
|
||||||
|
// AutoRemove is off (the act-CLI --rm behavior; the shipped runner always sets
|
||||||
|
// AutoRemove). A cancelled run is not a failure to inspect, and the cancel-path post
|
||||||
|
// context now carries its own error container so a failing post step makes jobError
|
||||||
|
// non-nil — OR in rc.jobCancelled so cancellation still always tears the container down.
|
||||||
|
if rc.Config.AutoRemove || jobError == nil || rc.jobCancelled {
|
||||||
// always allow 1 min for stopping and removing the runner, even if we were cancelled
|
// always allow 1 min for stopping and removing the runner, even if we were cancelled
|
||||||
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
tryUploadJobSummary(ctx, rc)
|
tryUploadJobSummary(ctx, rc)
|
||||||
|
// For Gitea
|
||||||
|
// We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer`
|
||||||
|
// logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||||
|
// if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||||
|
// logger.Errorf("Error while cleaning services: %v", err)
|
||||||
|
// }
|
||||||
|
|
||||||
logger.Infof("Cleaning up container for job %s", rc.JobName)
|
logger.Infof("Cleaning up container for job %s", rc.JobName)
|
||||||
if err = info.stopContainer()(ctx); err != nil {
|
if err = info.stopContainer()(ctx); err != nil {
|
||||||
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
// We don't need to call `NewDockerNetworkRemoveExecutor` here since it is called by above `info.stopContainer`
|
||||||
|
// if !rc.IsHostEnv(ctx) && rc.Config.ContainerNetworkMode == "" {
|
||||||
|
// // clean network in docker mode only
|
||||||
|
// // if the value of `ContainerNetworkMode` is empty string,
|
||||||
|
// // it means that the network to which containers are connecting is created by `runner`,
|
||||||
|
// // so, we should remove the network at last.
|
||||||
|
// networkName, _ := rc.networkName()
|
||||||
|
// logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||||
|
// if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||||
|
// logger.Errorf("Error while cleaning network: %v", err)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
setJobResult(ctx, info, rc, jobError == nil)
|
setJobResult(ctx, info, rc, jobError == nil)
|
||||||
setJobOutputs(ctx, rc)
|
setJobOutputs(ctx, rc)
|
||||||
|
|
||||||
@@ -388,7 +415,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
|
|
||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
// set reusable workflow job result
|
// set reusable workflow job result
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, jobResult) // For Gitea
|
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,8 +514,7 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
|||||||
if !ok || len(body) == 0 {
|
if !ok || len(body) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Gitea renders summaries on the run page, so mask before the upload.
|
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, body)
|
||||||
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, []byte(rc.maskSecrets(string(body))))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,7 +650,15 @@ func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, execu
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
||||||
|
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
rawLogger := common.Logger(ctx).WithField("raw_output", true)
|
||||||
|
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||||
|
if rc.Config.LogOutput {
|
||||||
|
rawLogger.Infof("%s", s)
|
||||||
|
} else {
|
||||||
|
rawLogger.Debugf("%s", s)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
|
||||||
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -336,7 +336,6 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executedSteps: []string{
|
executedSteps: []string{
|
||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
@@ -528,39 +527,18 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type controllableDeadlineContext struct {
|
|
||||||
context.Context
|
|
||||||
done chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newControllableDeadlineContext(parent context.Context) *controllableDeadlineContext {
|
|
||||||
return &controllableDeadlineContext{Context: parent, done: make(chan struct{})}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *controllableDeadlineContext) Done() <-chan struct{} {
|
|
||||||
return ctx.done
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *controllableDeadlineContext) Err() error {
|
|
||||||
select {
|
|
||||||
case <-ctx.done:
|
|
||||||
return context.DeadlineExceeded
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctx *controllableDeadlineContext) expire() {
|
|
||||||
close(ctx.done)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewJobExecutorRunsPostStepsAfterTimeout guards the timeout-minutes cleanup
|
// TestNewJobExecutorRunsPostStepsAfterTimeout guards the timeout-minutes cleanup
|
||||||
// path: when a job exceeds its timeout the job context is DeadlineExceeded, but
|
// path: when a job exceeds its timeout the job context is DeadlineExceeded, but
|
||||||
// the post steps (cleanup hooks like actions/checkout post and cache save) must
|
// the post steps (cleanup hooks like actions/checkout post and cache save) must
|
||||||
// still run against a fresh, non-expired context, and the job must still be
|
// still run against a fresh, non-expired context, and the job must still be
|
||||||
// reported as failed.
|
// reported as failed.
|
||||||
func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
|
func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
|
||||||
ctx := newControllableDeadlineContext(common.WithJobErrorContainer(context.Background()))
|
ctx := common.WithJobErrorContainer(context.Background())
|
||||||
|
// The timeout is generous so the main step (which blocks on ctx.Done below) is
|
||||||
|
// always reached before the deadline fires; otherwise the pipeline would
|
||||||
|
// short-circuit before the step runs and the job error would never be set.
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
jim := &jobInfoMock{}
|
jim := &jobInfoMock{}
|
||||||
sfm := &stepFactoryMock{}
|
sfm := &stepFactoryMock{}
|
||||||
@@ -584,16 +562,19 @@ func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
|
|||||||
jim.On("startContainer").Return(func(ctx context.Context) error { return nil })
|
jim.On("startContainer").Return(func(ctx context.Context) error { return nil })
|
||||||
jim.On("interpolateOutputs").Return(func(ctx context.Context) error { return nil })
|
jim.On("interpolateOutputs").Return(func(ctx context.Context) error { return nil })
|
||||||
jim.On("closeContainer").Return(func(ctx context.Context) error { return nil })
|
jim.On("closeContainer").Return(func(ctx context.Context) error { return nil })
|
||||||
// The job timed out, so it must be reported as failed and still cleaned up.
|
// The job timed out, so it must be reported as failed. stopContainer is left
|
||||||
jim.On("stopContainer").Return(func(context.Context) error { return nil })
|
// unexpected on purpose: a timed-out (failed) job preserves its error state, so
|
||||||
|
// the graceful stop is skipped exactly like any other failure without AutoRemove.
|
||||||
jim.On("result", "failure")
|
jim.On("result", "failure")
|
||||||
|
|
||||||
sm := &stepMock{}
|
sm := &stepMock{}
|
||||||
sfm.On("newStep", stepModel, rc).Return(sm, nil)
|
sfm.On("newStep", stepModel, rc).Return(sm, nil)
|
||||||
sm.On("pre").Return(func(ctx context.Context) error { return nil })
|
sm.On("pre").Return(func(ctx context.Context) error { return nil })
|
||||||
sm.On("main").Return(func(stepCtx context.Context) error {
|
// The main step runs past the job timeout: it blocks until the job context is
|
||||||
ctx.expire()
|
// done, mirroring a step that overruns timeout-minutes.
|
||||||
return stepCtx.Err()
|
sm.On("main").Return(func(ctx context.Context) error {
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
})
|
})
|
||||||
|
|
||||||
var postRan bool
|
var postRan bool
|
||||||
@@ -1003,7 +984,12 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
|
|||||||
|
|
||||||
func newTestRC(wf *model.Workflow, matrix map[string]any) *RunContext {
|
func newTestRC(wf *model.Workflow, matrix map[string]any) *RunContext {
|
||||||
return &RunContext{
|
return &RunContext{
|
||||||
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }},
|
Config: &Config{
|
||||||
|
Workdir: ".",
|
||||||
|
Platforms: map[string]string{
|
||||||
|
"ubuntu-latest": "ubuntu-latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
StepResults: map[string]*model.StepResult{},
|
StepResults: map[string]*model.StepResult{},
|
||||||
Env: map[string]string{},
|
Env: map[string]string{},
|
||||||
Matrix: matrix,
|
Matrix: matrix,
|
||||||
@@ -1096,37 +1082,3 @@ func TestJobSetContinueOnError(t *testing.T) {
|
|||||||
assert.True(t, j.ContinueOnError)
|
assert.True(t, j.ContinueOnError)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTryUploadJobSummaryMasksSecrets(t *testing.T) {
|
|
||||||
var got string
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
got = string(body)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
cm := &containerMock{}
|
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{
|
|
||||||
name: "step-summary-0.md", body: "deployed true with s3cr3t and runtime-added via pr0xypw",
|
|
||||||
}))),
|
|
||||||
nil,
|
|
||||||
).Once()
|
|
||||||
|
|
||||||
rc := newJobSummaryRC(map[string]string{
|
|
||||||
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
||||||
"ACTIONS_RUNTIME_URL": server.URL,
|
|
||||||
"ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34),
|
|
||||||
"GITEA_RUN_ID": "12",
|
|
||||||
}, cm, 1)
|
|
||||||
rc.Config.Secrets = map[string]string{"TOK": "s3cr3t", "ACTIONS_STEP_DEBUG": "true"}
|
|
||||||
rc.Config.ExtraMasks = []string{"pr0xypw"}
|
|
||||||
rc.Masks = []string{"runtime-added"}
|
|
||||||
|
|
||||||
tryUploadJobSummary(context.Background(), rc)
|
|
||||||
|
|
||||||
assert.Equal(t, "deployed true with *** and *** via ***", got)
|
|
||||||
cm.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -64,9 +64,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
|||||||
}
|
}
|
||||||
// Processed even on failure, so a hook that exports what it managed to set up before
|
// Processed even on failure, so a hook that exports what it managed to set up before
|
||||||
// failing still hands it to the job.
|
// failing still hands it to the job.
|
||||||
if processErr := rc.processHookFileCommands(ctx); err == nil {
|
err = cmp.Or(err, rc.processHookFileCommands(ctx))
|
||||||
err = processErr
|
|
||||||
}
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
goURL "net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LocalRepositoryCache struct {
|
||||||
|
Parent ActionCache
|
||||||
|
LocalRepositories map[string]string
|
||||||
|
CacheDirCache map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalRepositoryCache) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
|
||||||
|
if dest, ok := l.LocalRepositories[fmt.Sprintf("%s@%s", url, ref)]; ok {
|
||||||
|
l.CacheDirCache[fmt.Sprintf("%s@%s", cacheDir, ref)] = dest
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
if purl, err := goURL.Parse(url); err == nil {
|
||||||
|
if dest, ok := l.LocalRepositories[fmt.Sprintf("%s@%s", strings.TrimPrefix(purl.Path, "/"), ref)]; ok {
|
||||||
|
l.CacheDirCache[fmt.Sprintf("%s@%s", cacheDir, ref)] = dest
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l.Parent.Fetch(ctx, cacheDir, url, ref, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalRepositoryCache) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
|
||||||
|
// sha is mapped to ref in fetch if there is a local override
|
||||||
|
if dest, ok := l.CacheDirCache[fmt.Sprintf("%s@%s", cacheDir, sha)]; ok {
|
||||||
|
srcPath := filepath.Join(dest, includePrefix)
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
tw := tar.NewWriter(buf)
|
||||||
|
defer tw.Close()
|
||||||
|
srcPath = filepath.Clean(srcPath)
|
||||||
|
fi, err := os.Lstat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tc := &filecollector.TarCollector{
|
||||||
|
TarWriter: tw,
|
||||||
|
}
|
||||||
|
if fi.IsDir() {
|
||||||
|
srcPrefix := srcPath
|
||||||
|
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||||
|
srcPrefix += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
fc := &filecollector.FileCollector{
|
||||||
|
Fs: &filecollector.DefaultFs{},
|
||||||
|
SrcPath: srcPath,
|
||||||
|
SrcPrefix: srcPrefix,
|
||||||
|
Handler: tc,
|
||||||
|
}
|
||||||
|
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var f io.ReadCloser
|
||||||
|
var linkname string
|
||||||
|
if fi.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
linkname, err = os.Readlink(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f, err = os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
|
err := tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return io.NopCloser(buf), nil
|
||||||
|
}
|
||||||
|
return l.Parent.GetTarArchive(ctx, cacheDir, sha, includePrefix)
|
||||||
|
}
|
||||||
+41
-69
@@ -8,8 +8,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json/jsontext"
|
"encoding/json"
|
||||||
"encoding/json/v2"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -79,7 +78,7 @@ type JobLoggerFactory interface {
|
|||||||
|
|
||||||
type jobLoggerFactoryContextKey string
|
type jobLoggerFactoryContextKey string
|
||||||
|
|
||||||
var jobLoggerFactoryContextKeyVal = jobLoggerFactoryContextKey("jobloggerkey")
|
var jobLoggerFactoryContextKeyVal = (jobLoggerFactoryContextKey)("jobloggerkey")
|
||||||
|
|
||||||
func WithJobLoggerFactory(ctx context.Context, factory JobLoggerFactory) context.Context {
|
func WithJobLoggerFactory(ctx context.Context, factory JobLoggerFactory) context.Context {
|
||||||
return context.WithValue(ctx, jobLoggerFactoryContextKeyVal, factory)
|
return context.WithValue(ctx, jobLoggerFactoryContextKeyVal, factory)
|
||||||
@@ -100,7 +99,10 @@ func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, m
|
|||||||
mux.Lock()
|
mux.Lock()
|
||||||
defer mux.Unlock()
|
defer mux.Unlock()
|
||||||
nextColor++
|
nextColor++
|
||||||
formatter = &jobLogFormatter{color: colors[nextColor%len(colors)]}
|
formatter = &jobLogFormatter{
|
||||||
|
color: colors[nextColor%len(colors)],
|
||||||
|
logPrefixJobID: config.LogPrefixJobID,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger = logrus.New()
|
logger = logrus.New()
|
||||||
@@ -122,7 +124,7 @@ func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, m
|
|||||||
|
|
||||||
logger.SetFormatter(&maskedFormatter{
|
logger.SetFormatter(&maskedFormatter{
|
||||||
Formatter: logger.Formatter,
|
Formatter: logger.Formatter,
|
||||||
masker: valueMasker(config.InsecureSecrets, config.maskers()),
|
masker: valueMasker(config.InsecureSecrets, config.Secrets),
|
||||||
})
|
})
|
||||||
rtn := logger.WithFields(logrus.Fields{
|
rtn := logger.WithFields(logrus.Fields{
|
||||||
"job": jobName,
|
"job": jobName,
|
||||||
@@ -216,7 +218,7 @@ func base64ShiftEncoder(shift int) func(string) string {
|
|||||||
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
||||||
// that do not. When v has none of those characters both forms are equal and deduplicated.
|
// that do not. When v has none of those characters both forms are equal and deduplicated.
|
||||||
func jsonStringEscape(v string) string {
|
func jsonStringEscape(v string) string {
|
||||||
encoded, err := json.Marshal(v, jsontext.EscapeForHTML(true))
|
encoded, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -227,22 +229,15 @@ func jsonStringEscape(v string) string {
|
|||||||
// JavaScript (JSON.stringify) or .NET action emits, so a secret containing < > or & is
|
// JavaScript (JSON.stringify) or .NET action emits, so a secret containing < > or & is
|
||||||
// masked in that form too.
|
// masked in that form too.
|
||||||
func jsonStringEscapeNoHTML(v string) string {
|
func jsonStringEscapeNoHTML(v string) string {
|
||||||
encoded, err := json.Marshal(v)
|
var buf bytes.Buffer
|
||||||
if err != nil {
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(v); err != nil {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return string(encoded[1 : len(encoded)-1])
|
// Encode appends a newline; drop it along with the surrounding quotes.
|
||||||
}
|
encoded := strings.TrimRight(buf.String(), "\n")
|
||||||
|
return encoded[1 : len(encoded)-1]
|
||||||
// AppendSecretMaskers skips the debug settings, as GitHub does: they arrive as secrets, but
|
|
||||||
// masking "true" would corrupt unrelated log lines and drop job outputs that say it.
|
|
||||||
func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
|
|
||||||
for k, v := range secrets {
|
|
||||||
if k != "ACTIONS_STEP_DEBUG" && k != "ACTIONS_RUNNER_DEBUG" {
|
|
||||||
oldnew = AppendSecretMasker(oldnew, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return oldnew
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
func AppendSecretMasker(oldnew []string, v string) []string {
|
||||||
@@ -280,9 +275,13 @@ func AppendSecretMasker(oldnew []string, v string) []string {
|
|||||||
|
|
||||||
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
|
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
|
||||||
// raw_output (command/stream) lines; there is no bypass by field.
|
// raw_output (command/stream) lines; there is no bypass by field.
|
||||||
func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
|
||||||
|
var oldnew []string
|
||||||
|
for _, v := range secrets {
|
||||||
|
oldnew = AppendSecretMasker(oldnew, v)
|
||||||
|
}
|
||||||
oldnew = slices.Clip(oldnew)
|
oldnew = slices.Clip(oldnew)
|
||||||
defReplacer := NewSecretReplacer(oldnew)
|
defReplacer := strings.NewReplacer(oldnew...)
|
||||||
|
|
||||||
// A ::add-mask:: only ever appends to the job's mask slice, so the replacer built for
|
// A ::add-mask:: only ever appends to the job's mask slice, so the replacer built for
|
||||||
// it stays valid until the slice grows. Cache it, keyed by the slice itself and its
|
// it stays valid until the slice grows. Cache it, keyed by the slice itself and its
|
||||||
@@ -318,7 +317,7 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
|||||||
pairs = AppendSecretMasker(pairs, v)
|
pairs = AppendSecretMasker(pairs, v)
|
||||||
}
|
}
|
||||||
masked = len(*masks)
|
masked = len(*masks)
|
||||||
replacer = NewSecretReplacer(pairs)
|
replacer = strings.NewReplacer(pairs...)
|
||||||
}
|
}
|
||||||
cmasker := replacer
|
cmasker := replacer
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
@@ -340,6 +339,7 @@ func (f *maskedFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
|||||||
|
|
||||||
type jobLogFormatter struct {
|
type jobLogFormatter struct {
|
||||||
color int
|
color int
|
||||||
|
logPrefixJobID bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *jobLogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
func (f *jobLogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||||
@@ -363,23 +363,27 @@ func (f *jobLogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
|||||||
func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
|
func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
|
||||||
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
||||||
|
|
||||||
job := entry.Data["job"]
|
var job any
|
||||||
|
if f.logPrefixJobID {
|
||||||
|
job = entry.Data["jobID"]
|
||||||
|
} else {
|
||||||
|
job = entry.Data["job"]
|
||||||
|
}
|
||||||
|
|
||||||
debugFlag := ""
|
debugFlag := ""
|
||||||
if entry.Level == logrus.DebugLevel {
|
if entry.Level == logrus.DebugLevel {
|
||||||
debugFlag = "[DEBUG] "
|
debugFlag = "[DEBUG] "
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
if entry.Data[rawOutputField] == true {
|
||||||
case entry.Data[rawOutputField] == true:
|
|
||||||
if entry.Data[scriptLineCyanField] == true {
|
if entry.Data[scriptLineCyanField] == true {
|
||||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message)
|
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
|
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
|
||||||
}
|
}
|
||||||
case entry.Data["dryrun"] == true:
|
} else if entry.Data["dryrun"] == true {
|
||||||
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message)
|
||||||
default:
|
} else {
|
||||||
fmt.Fprintf(b, "\x1b[%dm[%s] \x1b[0m%s%s", f.color, job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "\x1b[%dm[%s] \x1b[0m%s%s", f.color, job, debugFlag, entry.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,19 +391,23 @@ func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
|
|||||||
func (f *jobLogFormatter) print(b *bytes.Buffer, entry *logrus.Entry) {
|
func (f *jobLogFormatter) print(b *bytes.Buffer, entry *logrus.Entry) {
|
||||||
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
||||||
|
|
||||||
job := entry.Data["job"]
|
var job any
|
||||||
|
if f.logPrefixJobID {
|
||||||
|
job = entry.Data["jobID"]
|
||||||
|
} else {
|
||||||
|
job = entry.Data["job"]
|
||||||
|
}
|
||||||
|
|
||||||
debugFlag := ""
|
debugFlag := ""
|
||||||
if entry.Level == logrus.DebugLevel {
|
if entry.Level == logrus.DebugLevel {
|
||||||
debugFlag = "[DEBUG] "
|
debugFlag = "[DEBUG] "
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
if entry.Data[rawOutputField] == true {
|
||||||
case entry.Data[rawOutputField] == true:
|
|
||||||
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
||||||
case entry.Data["dryrun"] == true:
|
} else if entry.Data["dryrun"] == true {
|
||||||
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)
|
||||||
default:
|
} else {
|
||||||
fmt.Fprintf(b, "[%s] %s%s", job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "[%s] %s%s", job, debugFlag, entry.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -426,39 +434,3 @@ func checkIfTerminal(w io.Writer) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// maskSecrets hides this job's secrets in a value that reaches somewhere the log maskers cannot,
|
|
||||||
// such as a container name or a job summary. Masks added at runtime count, so a summary written
|
|
||||||
// after ::add-mask:: is covered too.
|
|
||||||
func (rc *RunContext) maskSecrets(value string) string {
|
|
||||||
oldnew := rc.Config.maskers()
|
|
||||||
for _, mask := range rc.Masks {
|
|
||||||
oldnew = AppendSecretMasker(oldnew, mask)
|
|
||||||
}
|
|
||||||
return NewSecretReplacer(oldnew).Replace(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// maskers is every value this job's configuration says to hide, whatever the sink.
|
|
||||||
func (c *Config) maskers() []string {
|
|
||||||
oldnew := AppendSecretMaskers(nil, c.Secrets)
|
|
||||||
for _, mask := range c.ExtraMasks {
|
|
||||||
oldnew = AppendSecretMasker(oldnew, mask)
|
|
||||||
}
|
|
||||||
return oldnew
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSecretReplacer masks the longest secret first. Replacer matches in argument order, so a
|
|
||||||
// secret that prefixes another would otherwise mask only that prefix and print the rest.
|
|
||||||
func NewSecretReplacer(oldnew []string) *strings.Replacer {
|
|
||||||
pairs := make([][2]string, 0, len(oldnew)/2)
|
|
||||||
for i := 0; i+1 < len(oldnew); i += 2 {
|
|
||||||
pairs = append(pairs, [2]string{oldnew[i], oldnew[i+1]})
|
|
||||||
}
|
|
||||||
slices.SortFunc(pairs, func(a, b [2]string) int { return len(b[0]) - len(a[0]) })
|
|
||||||
|
|
||||||
sorted := make([]string, 0, len(pairs)*2)
|
|
||||||
for _, pair := range pairs {
|
|
||||||
sorted = append(sorted, pair[0], pair[1])
|
|
||||||
}
|
|
||||||
return strings.NewReplacer(sorted...)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestValueMasker(t *testing.T) {
|
|||||||
for _, entry := range table {
|
for _, entry := range table {
|
||||||
t.Run(entry.name, func(t *testing.T) {
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
ctx := WithMasks(t.Context(), &entry.masks)
|
ctx := WithMasks(t.Context(), &entry.masks)
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
|
masker := valueMasker(false, entry.secrets)
|
||||||
for line := range strings.SplitSeq(entry.lines, "\n") {
|
for line := range strings.SplitSeq(entry.lines, "\n") {
|
||||||
lentry := masker(&logrus.Entry{
|
lentry := masker(&logrus.Entry{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
@@ -65,7 +65,7 @@ func TestValueMasker(t *testing.T) {
|
|||||||
// URL — must be masked as well: masking only the verbatim value leaks it.
|
// URL — must be masked as well: masking only the verbatim value leaks it.
|
||||||
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||||
secret := `p@ss w"rd/1`
|
secret := `p@ss w"rd/1`
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -94,7 +94,7 @@ func TestValueMaskerEncodedSecrets(t *testing.T) {
|
|||||||
// form, so a JS-serialized JSON body does not leak it.
|
// form, so a JS-serialized JSON body does not leak it.
|
||||||
func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
||||||
secret := `a"<b>&c`
|
secret := `a"<b>&c`
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -112,20 +112,10 @@ func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// With debug logging on, the job logger writes to stdout, which no reporter masks, so it has
|
|
||||||
// to hide the values that are not job secrets too.
|
|
||||||
func TestValueMaskerHidesExtraMasks(t *testing.T) {
|
|
||||||
masker := valueMasker(false, (&Config{ExtraMasks: []string{"pr0xypw"}}).maskers())
|
|
||||||
|
|
||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: "proxy is http://user:pr0xypw@proxy:3128"})
|
|
||||||
|
|
||||||
assert.Equal(t, "proxy is http://user:***@proxy:3128", entry.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
||||||
func TestValueMaskerEncodedMasks(t *testing.T) {
|
func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||||
masks := []string{"s3cr3t value"}
|
masks := []string{"s3cr3t value"}
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, nil))
|
masker := valueMasker(false, nil)
|
||||||
|
|
||||||
entry := masker(&logrus.Entry{
|
entry := masker(&logrus.Entry{
|
||||||
Context: WithMasks(t.Context(), &masks),
|
Context: WithMasks(t.Context(), &masks),
|
||||||
@@ -141,7 +131,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
|
|||||||
// the token to anyone who can decode the log.
|
// the token to anyone who can decode the log.
|
||||||
func TestValueMaskerBase64Alignments(t *testing.T) {
|
func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||||
secret := "s3cr3t-token-value"
|
secret := "s3cr3t-token-value"
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
||||||
|
|
||||||
// One prefix per alignment: len%3 of 0, 1 and 2.
|
// One prefix per alignment: len%3 of 0, 1 and 2.
|
||||||
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
||||||
@@ -165,7 +155,7 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
|
|||||||
// The masker caches its replacer, so it has to notice both a mask appended to the same
|
// The masker caches its replacer, so it has to notice both a mask appended to the same
|
||||||
// slice and a composite action logging with a slice of its own.
|
// slice and a composite action logging with a slice of its own.
|
||||||
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
|
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": "secret-token"}))
|
masker := valueMasker(false, map[string]string{"TOKEN": "secret-token"})
|
||||||
mask := func(masks *[]string, message string) string {
|
mask := func(masks *[]string, message string) string {
|
||||||
return masker(&logrus.Entry{Context: WithMasks(t.Context(), masks), Message: message}).Message
|
return masker(&logrus.Entry{Context: WithMasks(t.Context(), masks), Message: message}).Message
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,20 +65,3 @@ func TestMaxParallelStrategy(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
|
|
||||||
var rawMatrix yaml.Node
|
|
||||||
require.NoError(t, rawMatrix.Encode(map[string]any{
|
|
||||||
"config": map[string]any{"nested": "value"},
|
|
||||||
}))
|
|
||||||
|
|
||||||
plan := &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{
|
|
||||||
Workflow: &model.Workflow{Jobs: map[string]*model.Job{
|
|
||||||
"test": {Strategy: &model.Strategy{RawMatrix: rawMatrix}},
|
|
||||||
}},
|
|
||||||
JobID: "test",
|
|
||||||
}}}}}
|
|
||||||
runner := &runnerImpl{config: &Config{}}
|
|
||||||
|
|
||||||
require.ErrorContains(t, runner.NewPlanExecutor(plan)(t.Context()), "could not get job matrix:")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -15,8 +16,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||||
@@ -77,6 +77,10 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
||||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
||||||
|
|
||||||
|
if rc.Config.ActionCache != nil {
|
||||||
|
return newActionCacheReusableWorkflowExecutor(rc, filename, remoteReusableWorkflow)
|
||||||
|
}
|
||||||
|
|
||||||
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
|
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
@@ -85,6 +89,41 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newActionCacheReusableWorkflowExecutor(rc *RunContext, filename string, remoteReusableWorkflow *remoteReusableWorkflow) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
ghctx := rc.getGithubContext(ctx)
|
||||||
|
remoteReusableWorkflow.URL = ghctx.ServerURL
|
||||||
|
sha, err := rc.Config.ActionCache.Fetch(ctx, filename, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, ghctx.Token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
archive, err := rc.Config.ActionCache.GetTarArchive(ctx, filename, sha, ".github/workflows/"+remoteReusableWorkflow.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer archive.Close()
|
||||||
|
treader := tar.NewReader(archive)
|
||||||
|
if _, err = treader.Next(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
planner, err := model.NewSingleWorkflowPlanner(remoteReusableWorkflow.Filename, treader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plan, err := planner.PlanEvent("workflow_call")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
runner, err := NewReusableWorkflowRunner(rc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return runner.NewPlanExecutor(plan)(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cloneRemoteReusableWorkflow always invokes the clone executor — moving refs
|
// cloneRemoteReusableWorkflow always invokes the clone executor — moving refs
|
||||||
// (branches, tags) must be re-resolved each run, matching GitHub Actions.
|
// (branches, tags) must be re-resolved each run, matching GitHub Actions.
|
||||||
//
|
//
|
||||||
@@ -107,12 +146,15 @@ func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var modelNewWorkflowPlanner = model.NewWorkflowPlanner
|
||||||
|
|
||||||
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor {
|
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
// Serialize workflow reads with cache updates.
|
// Scoped to the yaml read so concurrent invocations don't serialize
|
||||||
|
// on the whole job run.
|
||||||
planner, err := func() (model.WorkflowPlanner, error) {
|
planner, err := func() (model.WorkflowPlanner, error) {
|
||||||
defer git.AcquireCloneLock(directory)()
|
defer git.AcquireCloneLock(directory)()
|
||||||
return model.NewWorkflowPlanner(path.Join(directory, workflow), true)
|
return modelNewWorkflowPlanner(path.Join(directory, workflow), true)
|
||||||
}()
|
}()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -123,11 +165,12 @@ func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) com
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
runner, err := newReusableWorkflowRunner(rc)
|
runner, err := NewReusableWorkflowRunner(rc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// return runner.NewPlanExecutor(plan)(ctx)
|
||||||
return common.NewPipelineExecutor( // For Gitea
|
return common.NewPipelineExecutor( // For Gitea
|
||||||
runner.NewPlanExecutor(plan),
|
runner.NewPlanExecutor(plan),
|
||||||
setReusedWorkflowCallerResult(rc, runner),
|
setReusedWorkflowCallerResult(rc, runner),
|
||||||
@@ -135,7 +178,7 @@ func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) com
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) {
|
func NewReusableWorkflowRunner(rc *RunContext) (Runner, error) {
|
||||||
runner := &runnerImpl{
|
runner := &runnerImpl{
|
||||||
config: rc.Config,
|
config: rc.Config,
|
||||||
eventJSON: rc.EventJSON,
|
eventJSON: rc.EventJSON,
|
||||||
@@ -211,9 +254,16 @@ func newRemoteReusableWorkflowFromAbsoluteURL(uses string) *remoteReusableWorkfl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Executor {
|
func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
caller := runner.caller
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
runnerImpl, ok := runner.(*runnerImpl)
|
||||||
|
if !ok {
|
||||||
|
logger.Warn("Failed to get caller from runner")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
caller := runnerImpl.caller
|
||||||
|
|
||||||
allJobDone := true
|
allJobDone := true
|
||||||
hasFailure := false
|
hasFailure := false
|
||||||
@@ -236,14 +286,14 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Ex
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, reusedWorkflowJobResult)
|
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult)
|
||||||
} else {
|
} else {
|
||||||
// Serialize this shared Job.Result write against the other matrix combos
|
// Serialize this shared Job.Result write against the other matrix combos
|
||||||
// and setJobResult (same lockJob key).
|
// and setJobResult (same lockJob key).
|
||||||
unlock := lockJob(rc.Run.Job())
|
unlock := lockJob(rc.Run.Job())
|
||||||
rc.result(reusedWorkflowJobResult)
|
rc.result(reusedWorkflowJobResult)
|
||||||
unlock()
|
unlock()
|
||||||
common.Logger(ctx).WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -13,8 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,11 +77,19 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|||||||
|
|
||||||
func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
||||||
workflowDir := t.TempDir()
|
workflowDir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "reusable.yml"), []byte(":"), 0o644))
|
|
||||||
|
|
||||||
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir))
|
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir))
|
||||||
defer unlockOnce()
|
defer unlockOnce()
|
||||||
|
|
||||||
|
plannerCalled := make(chan struct{})
|
||||||
|
|
||||||
|
origPlanner := modelNewWorkflowPlanner
|
||||||
|
modelNewWorkflowPlanner = func(string, bool) (model.WorkflowPlanner, error) {
|
||||||
|
close(plannerCalled)
|
||||||
|
return nil, errors.New("stop")
|
||||||
|
}
|
||||||
|
defer func() { modelNewWorkflowPlanner = origPlanner }()
|
||||||
|
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
|
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
|
||||||
@@ -91,18 +100,26 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
|||||||
go func() { done <- exec(context.Background()) }()
|
go func() { done <- exec(context.Background()) }()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
case <-plannerCalled:
|
||||||
|
t.Fatal("planner ran while clone lock was held")
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
t.Fatalf("executor returned while clone lock was held: %v", err)
|
t.Fatalf("executor returned before planner was reached: %v", err)
|
||||||
case <-time.After(50 * time.Millisecond):
|
case <-time.After(50 * time.Millisecond):
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockOnce()
|
unlockOnce()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-plannerCalled:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("planner not called after lock was released")
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
t.Fatal("executor did not return after lock was released")
|
t.Fatal("executor did not return after planner ran")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+110
-70
@@ -11,7 +11,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json/v2"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -27,11 +27,10 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/ghcontext"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
@@ -56,9 +55,10 @@ type RunContext struct {
|
|||||||
CurrentStepIndex int
|
CurrentStepIndex int
|
||||||
StepResults map[string]*model.StepResult
|
StepResults map[string]*model.StepResult
|
||||||
IntraActionState map[string]map[string]string
|
IntraActionState map[string]map[string]string
|
||||||
ExprEval *expressionEvaluator
|
ExprEval ExpressionEvaluator
|
||||||
JobContainer container.ExecutionsEnvironment
|
JobContainer container.ExecutionsEnvironment
|
||||||
serviceContainers []*serviceContainer
|
serviceContainers []*serviceContainer
|
||||||
|
OutputMappings map[MappableOutput]MappableOutput
|
||||||
JobName string
|
JobName string
|
||||||
ActionPath string
|
ActionPath string
|
||||||
Parent *RunContext
|
Parent *RunContext
|
||||||
@@ -147,6 +147,11 @@ func (rc *RunContext) AddMask(mask string) {
|
|||||||
rc.Masks = append(rc.Masks, mask)
|
rc.Masks = append(rc.Masks, mask)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MappableOutput struct {
|
||||||
|
StepID string
|
||||||
|
OutputName string
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) String() string {
|
func (rc *RunContext) String() string {
|
||||||
name := fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name)
|
name := fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name)
|
||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
@@ -180,8 +185,7 @@ func (rc *RunContext) GetEnv() map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) jobContainerName() string {
|
func (rc *RunContext) jobContainerName() string {
|
||||||
// The job id, never evaluated, keeps two jobs apart when masking collapses their names.
|
nameParts := []string{rc.Config.ContainerNamePrefix, "WORKFLOW-" + rc.Run.Workflow.Name, "JOB-" + rc.Name}
|
||||||
nameParts := []string{rc.Config.ContainerNamePrefix, "WORKFLOW-" + rc.Run.Workflow.Name, "JOB-" + rc.Run.JobID, rc.Name}
|
|
||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
|
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
|
||||||
}
|
}
|
||||||
@@ -199,15 +203,14 @@ func (rc *RunContext) networkNameForGitea() (string, bool) {
|
|||||||
func getDockerDaemonSocketMountPath(daemonPath string) string {
|
func getDockerDaemonSocketMountPath(daemonPath string) string {
|
||||||
if before, after, ok := strings.Cut(daemonPath, "://"); ok {
|
if before, after, ok := strings.Cut(daemonPath, "://"); ok {
|
||||||
scheme := before
|
scheme := before
|
||||||
switch {
|
if strings.EqualFold(scheme, "npipe") {
|
||||||
case strings.EqualFold(scheme, "npipe"):
|
|
||||||
// linux container mount on windows, use the default socket path of the VM / wsl2
|
// linux container mount on windows, use the default socket path of the VM / wsl2
|
||||||
return "/var/run/docker.sock"
|
return "/var/run/docker.sock"
|
||||||
case strings.EqualFold(scheme, "unix"):
|
} else if strings.EqualFold(scheme, "unix") {
|
||||||
return after
|
return after
|
||||||
case strings.IndexFunc(scheme, func(r rune) bool {
|
} else if strings.IndexFunc(scheme, func(r rune) bool {
|
||||||
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z')
|
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z')
|
||||||
}) == -1:
|
}) == -1 {
|
||||||
// unknown protocol use default
|
// unknown protocol use default
|
||||||
return "/var/run/docker.sock"
|
return "/var/run/docker.sock"
|
||||||
}
|
}
|
||||||
@@ -225,19 +228,14 @@ func (rc *RunContext) containerDaemonSocket() string {
|
|||||||
return rc.Config.ContainerDaemonSocket
|
return rc.Config.ContainerDaemonSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cache is shared
|
|
||||||
|
|
||||||
// validVolumes returns the volumes allowed on this job's containers: the configured base
|
// validVolumes returns the volumes allowed on this job's containers: the configured base
|
||||||
// plus the volumes the runner mounts automatically. It derives a fresh slice every call and
|
// plus the volumes the runner mounts automatically. It derives a fresh slice every call and
|
||||||
// never mutates the shared Config (see containerDaemonSocket).
|
// never mutates the shared Config (see containerDaemonSocket).
|
||||||
func (rc *RunContext) validVolumes() []string {
|
func (rc *RunContext) validVolumes() []string {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
volumes := slices.Clone(rc.Config.ValidVolumes)
|
volumes := slices.Clone(rc.Config.ValidVolumes)
|
||||||
if rc.Config.SharedToolCache {
|
|
||||||
volumes = append(volumes, sharedToolCacheVolume)
|
|
||||||
}
|
|
||||||
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
||||||
return append(volumes, name, name+"-env",
|
return append(volumes, "act-toolcache", name, name+"-env",
|
||||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,10 +308,8 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||||
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||||
}
|
}
|
||||||
if rc.Config.SharedToolCache {
|
|
||||||
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||||
mounts[sharedToolCacheVolume] = toolCache
|
mounts["act-toolcache"] = toolCache
|
||||||
}
|
|
||||||
}
|
}
|
||||||
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||||
|
|
||||||
@@ -337,7 +333,16 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
|
|
||||||
func (rc *RunContext) startHostEnvironment() common.Executor {
|
func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
rawLogger := logger.WithField(rawOutputField, true)
|
||||||
|
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||||
|
if rc.Config.LogOutput {
|
||||||
|
rawLogger.Infof("%s", s)
|
||||||
|
} else {
|
||||||
|
rawLogger.Debugf("%s", s)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
cacheDir := rc.ActionCacheDir()
|
cacheDir := rc.ActionCacheDir()
|
||||||
randBytes := make([]byte, 8)
|
randBytes := make([]byte, 8)
|
||||||
_, _ = rand.Read(randBytes)
|
_, _ = rand.Read(randBytes)
|
||||||
@@ -354,11 +359,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
|
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
toolCacheParent := miscpath // per job, so cleanup removes it with the job
|
toolCache := rc.toolCache(filepath.Join(cacheDir, "tool_cache"))
|
||||||
if rc.Config.SharedToolCache {
|
|
||||||
toolCacheParent = cacheDir
|
|
||||||
}
|
|
||||||
toolCache := rc.toolCache(filepath.Join(toolCacheParent, "tool_cache"))
|
|
||||||
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -424,7 +425,15 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
image := rc.platformImage(ctx)
|
image := rc.platformImage(ctx)
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
rawLogger := logger.WithField(rawOutputField, true)
|
||||||
|
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||||
|
if rc.Config.LogOutput {
|
||||||
|
rawLogger.Infof("%s", s)
|
||||||
|
} else {
|
||||||
|
rawLogger.Debugf("%s", s)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
username, password, err := rc.handleCredentials(ctx)
|
username, password, err := rc.handleCredentials(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -476,7 +485,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
}
|
}
|
||||||
// keep these local: reusing username/password would overwrite the
|
// keep these local: reusing username/password would overwrite the
|
||||||
// credentials the job container is pulled with further down
|
// credentials the job container is pulled with further down
|
||||||
serviceUsername, servicePassword, err := rc.interpolateCredentials(ctx, spec.Credentials, "")
|
serviceUsername, servicePassword, err := rc.handleServiceCredentials(ctx, spec.Credentials)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
||||||
}
|
}
|
||||||
@@ -513,7 +522,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
||||||
WorkflowOptions: rc.ExprEval.Interpolate(ctx, spec.Options),
|
Options: rc.ExprEval.Interpolate(ctx, spec.Options),
|
||||||
NetworkMode: networkName,
|
NetworkMode: networkName,
|
||||||
NetworkAliases: []string{serviceID},
|
NetworkAliases: []string{serviceID},
|
||||||
ExposedPorts: exposedPorts,
|
ExposedPorts: exposedPorts,
|
||||||
@@ -546,14 +555,13 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
Privileged: rc.Config.Privileged,
|
Privileged: rc.Config.Privileged,
|
||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
RunnerOptions: rc.Config.ContainerOptions,
|
Options: rc.options(ctx),
|
||||||
WorkflowOptions: rc.workflowOptions(ctx),
|
AutoRemove: rc.Config.AutoRemove,
|
||||||
AutoRemove: true,
|
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.validVolumes(),
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
})
|
||||||
if rc.JobContainer == nil {
|
if rc.JobContainer == nil {
|
||||||
return errors.New("failed to create job container")
|
return errors.New("Failed to create job container")
|
||||||
}
|
}
|
||||||
|
|
||||||
rc.jobNetworkName = networkName
|
rc.jobNetworkName = networkName
|
||||||
@@ -584,20 +592,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
|
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
|
||||||
return common.NewLineWriter(rc.commandHandler(ctx), func(line string) bool {
|
|
||||||
rawLogger.Infof("%s", line)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupJobResources removes everything the job created, continuing past failures.
|
// cleanupJobResources removes everything the job created, continuing past failures.
|
||||||
// Only job container and volume errors are returned, the rest are logged.
|
// Only job container and volume errors are returned, the rest are logged.
|
||||||
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
|
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
removeJobContainer := rc.JobContainer != nil
|
removeJobContainer := rc.JobContainer != nil && !rc.Config.ReuseContainers
|
||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
if removeJobContainer {
|
if removeJobContainer {
|
||||||
@@ -627,6 +627,12 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) execJobContainer(cmd []string, env map[string]string, user, workdir string) common.Executor { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) {
|
func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) {
|
||||||
if len(rc.ExtraPath) > 0 {
|
if len(rc.ExtraPath) > 0 {
|
||||||
path := rc.JobContainer.GetPathVariableName()
|
path := rc.JobContainer.GetPathVariableName()
|
||||||
@@ -1027,7 +1033,7 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
|||||||
// unfinished. rc.caller is only set for reusable workflows.
|
// unfinished. rc.caller is only set for reusable workflows.
|
||||||
rc.result("failure")
|
rc.result("failure")
|
||||||
if rc.caller != nil { // For Gitea
|
if rc.caller != nil { // For Gitea
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "failure")
|
rc.caller.setReusedWorkflowJobResult(rc.JobName, "failure")
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1060,9 +1066,19 @@ func (rc *RunContext) runsOnImage(ctx context.Context) string {
|
|||||||
runsOn[i] = rc.ExprEval.Interpolate(ctx, v)
|
runsOn[i] = rc.ExprEval.Interpolate(ctx, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rc.Config.PlatformPicker != nil {
|
if pick := rc.Config.PlatformPicker; pick != nil {
|
||||||
return rc.Config.PlatformPicker(runsOn)
|
if image := pick(runsOn); image != "" {
|
||||||
|
return image
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, platformName := range rc.runsOnPlatformNames(ctx) {
|
||||||
|
image := rc.Config.Platforms[strings.ToLower(platformName)]
|
||||||
|
if image != "" {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1092,13 +1108,14 @@ func (rc *RunContext) platformImage(ctx context.Context) string {
|
|||||||
return rc.runsOnImage(ctx)
|
return rc.runsOnImage(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) workflowOptions(ctx context.Context) string {
|
func (rc *RunContext) options(ctx context.Context) string {
|
||||||
c := rc.Run.Job().Container()
|
job := rc.Run.Job()
|
||||||
if c == nil {
|
c := job.Container()
|
||||||
return ""
|
if c != nil {
|
||||||
|
return rc.Config.ContainerOptions + " " + rc.ExprEval.Interpolate(ctx, c.Options)
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc.ExprEval.Interpolate(ctx, c.Options)
|
return rc.Config.ContainerOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
||||||
@@ -1117,7 +1134,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
|||||||
|
|
||||||
if !runJob {
|
if !runJob {
|
||||||
if rc.caller != nil { // For Gitea
|
if rc.caller != nil { // For Gitea
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "skipped")
|
rc.caller.setReusedWorkflowJobResult(rc.JobName, "skipped")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
l.WithField("jobResult", "skipped").Debugf("Skipping job '%s' due to '%s'", job.Name, job.If.Value)
|
l.WithField("jobResult", "skipped").Debugf("Skipping job '%s' due to '%s'", job.Name, job.If.Value)
|
||||||
@@ -1338,12 +1355,12 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
|
|
||||||
ghc.SetBaseAndHeadRef()
|
ghc.SetBaseAndHeadRef()
|
||||||
repoPath := rc.Config.Workdir
|
repoPath := rc.Config.Workdir
|
||||||
ghcontext.SetRepositoryAndOwner(ctx, ghc, rc.Config.GitHubInstance, repoPath)
|
ghc.SetRepositoryAndOwner(ctx, rc.Config.GitHubInstance, rc.Config.RemoteName, repoPath)
|
||||||
if ghc.Ref == "" {
|
if ghc.Ref == "" {
|
||||||
ghcontext.SetRef(ctx, ghc, repoPath)
|
ghc.SetRef(ctx, rc.Config.DefaultBranch, repoPath)
|
||||||
}
|
}
|
||||||
if ghc.Sha == "" {
|
if ghc.Sha == "" {
|
||||||
ghcontext.SetSha(ctx, ghc, repoPath)
|
ghc.SetSha(ctx, repoPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
@@ -1556,30 +1573,53 @@ func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, er
|
|||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc.interpolateCredentials(ctx, container.Credentials, "container.")
|
if len(container.Credentials) != 2 {
|
||||||
}
|
err := errors.New("invalid property count for key 'credentials:'")
|
||||||
|
return "", "", err
|
||||||
func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials map[string]string, prefix string) (string, string, error) {
|
|
||||||
if credentials == nil {
|
|
||||||
return "", "", nil
|
|
||||||
}
|
|
||||||
if len(credentials) != 2 {
|
|
||||||
return "", "", errors.New("invalid property count for key 'credentials:'")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
ee := rc.NewExpressionEvaluator(ctx)
|
||||||
username := ee.Interpolate(ctx, credentials["username"])
|
var username, password string
|
||||||
if username == "" {
|
if username = ee.Interpolate(ctx, container.Credentials["username"]); username == "" {
|
||||||
return "", "", errors.New("failed to interpolate " + prefix + "credentials.username")
|
err := errors.New("failed to interpolate container.credentials.username")
|
||||||
|
return "", "", err
|
||||||
}
|
}
|
||||||
password := ee.Interpolate(ctx, credentials["password"])
|
if password = ee.Interpolate(ctx, container.Credentials["password"]); password == "" {
|
||||||
if password == "" {
|
err := errors.New("failed to interpolate container.credentials.password")
|
||||||
return "", "", errors.New("failed to interpolate " + prefix + "credentials.password")
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if container.Credentials["username"] == "" || container.Credentials["password"] == "" {
|
||||||
|
err := errors.New("container.credentials cannot be empty")
|
||||||
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return username, password, nil
|
return username, password, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[string]string) (username, password string, err error) {
|
||||||
|
if creds == nil {
|
||||||
|
return username, password, err
|
||||||
|
}
|
||||||
|
if len(creds) != 2 {
|
||||||
|
err = errors.New("invalid property count for key 'credentials:'")
|
||||||
|
return username, password, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ee := rc.NewExpressionEvaluator(ctx)
|
||||||
|
if username = ee.Interpolate(ctx, creds["username"]); username == "" {
|
||||||
|
err = errors.New("failed to interpolate credentials.username")
|
||||||
|
return username, password, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if password = ee.Interpolate(ctx, creds["password"]); password == "" {
|
||||||
|
err = errors.New("failed to interpolate credentials.password")
|
||||||
|
return username, password, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return username, password, err
|
||||||
|
}
|
||||||
|
|
||||||
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
||||||
binds, mounts, claimed := splitVolumes(svcVolumes)
|
binds, mounts, claimed := splitVolumes(svcVolumes)
|
||||||
|
|||||||
+79
-102
@@ -17,9 +17,9 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
@@ -215,11 +215,8 @@ type fakeContainer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (fakeContainer) Pull(bool) common.Executor { return func(context.Context) error { return nil } }
|
func (fakeContainer) Pull(bool) common.Executor { return func(context.Context) error { return nil } }
|
||||||
|
|
||||||
func (fakeContainer) Start(bool) common.Executor { return func(context.Context) error { return nil } }
|
func (fakeContainer) Start(bool) common.Executor { return func(context.Context) error { return nil } }
|
||||||
|
|
||||||
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
|
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
|
||||||
|
|
||||||
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
|
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
|
||||||
func (fakeContainer) GetActPath() string { return "/var/run/act" }
|
func (fakeContainer) GetActPath() string { return "/var/run/act" }
|
||||||
func (fakeContainer) Create([]string, []string) common.Executor {
|
func (fakeContainer) Create([]string, []string) common.Executor {
|
||||||
@@ -236,48 +233,10 @@ func (fakeContainer) Inspect(context.Context) (*container.Info, error) {
|
|||||||
|
|
||||||
func (fakeContainer) DumpLogs(context.Context) error { return nil }
|
func (fakeContainer) DumpLogs(context.Context) error { return nil }
|
||||||
|
|
||||||
// startJobContainerInputs runs startJobContainer against fakeContainer and returns the
|
|
||||||
// inputs it built, one per container.
|
|
||||||
func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*container.NewContainerInput {
|
|
||||||
t.Helper()
|
|
||||||
workflow, err := model.ReadWorkflow(strings.NewReader(workflowYAML))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var inputs []*container.NewContainerInput
|
|
||||||
origNewContainer := newContainer
|
|
||||||
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
|
||||||
inputs = append(inputs, input)
|
|
||||||
return fakeContainer{}
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { newContainer = origNewContainer })
|
|
||||||
|
|
||||||
cfg.Workdir = "/tmp"
|
|
||||||
cfg.ContainerNetworkMode = "host" // an explicit network mode creates no network
|
|
||||||
cfg.Env = map[string]string{}
|
|
||||||
cfg.Secrets = map[string]string{}
|
|
||||||
|
|
||||||
rc := &RunContext{
|
|
||||||
Name: "test",
|
|
||||||
Config: cfg,
|
|
||||||
Env: map[string]string{},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "job",
|
|
||||||
Workflow: workflow,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
|
||||||
|
|
||||||
// the inputs are built before the missing daemon fails the first call
|
|
||||||
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
|
||||||
require.Error(t, rc.startJobContainer()(t.Context()))
|
|
||||||
|
|
||||||
return inputs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression test: a service without a `credentials:` block resolves to empty
|
// Regression test: a service without a `credentials:` block resolves to empty
|
||||||
// credentials, which used to overwrite the job container's own credentials.
|
// credentials, which used to overwrite the job container's own credentials.
|
||||||
func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) {
|
func TestStartJobContainerKeepsJobCredentialsWithServices(t *testing.T) {
|
||||||
inputs := startJobContainerInputs(t, `
|
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
name: test
|
name: test
|
||||||
on: push
|
on: push
|
||||||
jobs:
|
jobs:
|
||||||
@@ -297,7 +256,37 @@ jobs:
|
|||||||
username: db-user
|
username: db-user
|
||||||
password: db-password
|
password: db-password
|
||||||
steps: []
|
steps: []
|
||||||
`, &Config{})
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var inputs []*container.NewContainerInput
|
||||||
|
origNewContainer := newContainer
|
||||||
|
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
|
inputs = append(inputs, input)
|
||||||
|
return fakeContainer{}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newContainer = origNewContainer })
|
||||||
|
|
||||||
|
rc := &RunContext{
|
||||||
|
Name: "test",
|
||||||
|
Config: &Config{
|
||||||
|
Workdir: "/tmp",
|
||||||
|
// no daemon: an explicit network mode creates no network, and
|
||||||
|
// reusing containers short-circuits the volume cleanup executors
|
||||||
|
ContainerNetworkMode: "host",
|
||||||
|
ReuseContainers: true,
|
||||||
|
Env: map[string]string{},
|
||||||
|
Secrets: map[string]string{},
|
||||||
|
},
|
||||||
|
Env: map[string]string{},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "job",
|
||||||
|
Workflow: workflow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
|
|
||||||
|
require.NoError(t, rc.startJobContainer()(t.Context()))
|
||||||
|
|
||||||
credentials := map[string][2]string{}
|
credentials := map[string][2]string{}
|
||||||
for _, in := range inputs {
|
for _, in := range inputs {
|
||||||
@@ -311,39 +300,10 @@ jobs:
|
|||||||
require.Equal(t, [2]string{"", ""}, credentials["redis:latest"])
|
require.Equal(t, [2]string{"", ""}, credentials["redis:latest"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the workflow's options may be stripped later, so the two sources have to reach the
|
|
||||||
// container apart from each other.
|
|
||||||
func TestStartJobContainerKeepsRunnerOptionsApartFromWorkflowOptions(t *testing.T) {
|
|
||||||
inputs := startJobContainerInputs(t, `
|
|
||||||
name: test
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
job:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: registry.example/job:latest
|
|
||||||
options: --cap-add SYS_PTRACE
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis:latest
|
|
||||||
options: --shm-size 1g
|
|
||||||
steps: []
|
|
||||||
`, &Config{ContainerOptions: "--device /dev/fuse"})
|
|
||||||
|
|
||||||
options := map[string][2]string{}
|
|
||||||
for _, in := range inputs {
|
|
||||||
options[in.Image] = [2]string{in.RunnerOptions, in.WorkflowOptions}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Equal(t, [2]string{"--device /dev/fuse", "--cap-add SYS_PTRACE"}, options["registry.example/job:latest"])
|
|
||||||
// a service container gets no options from the runner's config today
|
|
||||||
require.Equal(t, [2]string{"", "--shm-size 1g"}, options["redis:latest"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// A service container reaches the internet the same way the job does, so it inherits the
|
// A service container reaches the internet the same way the job does, so it inherits the
|
||||||
// job's proxy; a service that sets the variable itself keeps its own value.
|
// job's proxy; a service that sets the variable itself keeps its own value.
|
||||||
func TestStartJobContainerGivesServicesTheJobProxy(t *testing.T) {
|
func TestStartJobContainerGivesServicesTheJobProxy(t *testing.T) {
|
||||||
inputs := startJobContainerInputs(t, `
|
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
name: test
|
name: test
|
||||||
on: push
|
on: push
|
||||||
jobs:
|
jobs:
|
||||||
@@ -359,7 +319,36 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
no_proxy: db-only.example
|
no_proxy: db-only.example
|
||||||
steps: []
|
steps: []
|
||||||
`, &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}})
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var inputs []*container.NewContainerInput
|
||||||
|
origNewContainer := newContainer
|
||||||
|
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
|
inputs = append(inputs, input)
|
||||||
|
return fakeContainer{}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newContainer = origNewContainer })
|
||||||
|
|
||||||
|
rc := &RunContext{
|
||||||
|
Name: "test",
|
||||||
|
Config: &Config{
|
||||||
|
Workdir: "/tmp",
|
||||||
|
ContainerNetworkMode: "host",
|
||||||
|
ReuseContainers: true,
|
||||||
|
Env: map[string]string{},
|
||||||
|
ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"},
|
||||||
|
Secrets: map[string]string{},
|
||||||
|
},
|
||||||
|
Env: map[string]string{},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "job",
|
||||||
|
Workflow: workflow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
|
|
||||||
|
require.NoError(t, rc.startJobContainer()(t.Context()))
|
||||||
|
|
||||||
env := map[string][]string{}
|
env := map[string][]string{}
|
||||||
for _, in := range inputs {
|
for _, in := range inputs {
|
||||||
@@ -514,7 +503,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Config: &Config{
|
Config: &Config{
|
||||||
BindWorkdir: false,
|
BindWorkdir: false,
|
||||||
SharedToolCache: true, // so OverridesToolCache has a mount to displace
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
@@ -555,44 +543,25 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ToolCacheMount", func(t *testing.T) {
|
|
||||||
rc := &RunContext{
|
|
||||||
Name: "TestRCName",
|
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Name: "TestWorkflowName"}},
|
|
||||||
Config: &Config{},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, gotmount := rc.GetBindsAndMounts()
|
|
||||||
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
|
||||||
|
|
||||||
rc.Config.SharedToolCache = true
|
|
||||||
_, gotmount = rc.GetBindsAndMounts()
|
|
||||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextValidVolumes(t *testing.T) {
|
func TestRunContextValidVolumes(t *testing.T) {
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Name: "job",
|
Name: "job",
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
|
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
|
||||||
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}, SharedToolCache: true},
|
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}},
|
||||||
}
|
}
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
|
|
||||||
got := rc.validVolumes()
|
got := rc.validVolumes()
|
||||||
|
|
||||||
// the configured volumes plus the ones the runner mounts automatically
|
// the configured volumes plus the four the runner mounts automatically
|
||||||
assert.Subset(t, got, []string{"my-vol", "/host/path", sharedToolCacheVolume, name, name + "-env", "/var/run/docker.sock"})
|
assert.Subset(t, got, []string{"my-vol", "/host/path", "act-toolcache", name, name + "-env", "/var/run/docker.sock"})
|
||||||
|
|
||||||
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
|
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
|
||||||
// combinations share one *Config, and the previous in-place append was a data race.
|
// combinations share one *Config, and the previous in-place append was a data race.
|
||||||
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
|
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
|
||||||
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
||||||
|
|
||||||
// a job may mount it only while the runner does
|
|
||||||
rc.Config.SharedToolCache = false
|
|
||||||
assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||||
@@ -679,6 +648,7 @@ func TestGetGitHubContext(t *testing.T) {
|
|||||||
Env: map[string]string{},
|
Env: map[string]string{},
|
||||||
ExtraPath: []string{},
|
ExtraPath: []string{},
|
||||||
StepResults: map[string]*model.StepResult{},
|
StepResults: map[string]*model.StepResult{},
|
||||||
|
OutputMappings: map[MappableOutput]MappableOutput{},
|
||||||
}
|
}
|
||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
|
|
||||||
@@ -755,7 +725,12 @@ func TestGetGithubContextRef(t *testing.T) {
|
|||||||
|
|
||||||
func createIfTestRunContext(jobs map[string]*model.Job) *RunContext {
|
func createIfTestRunContext(jobs map[string]*model.Job) *RunContext {
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }},
|
Config: &Config{
|
||||||
|
Workdir: ".",
|
||||||
|
Platforms: map[string]string{
|
||||||
|
"ubuntu-latest": "ubuntu-latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
Env: map[string]string{},
|
Env: map[string]string{},
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: "job1",
|
JobID: "job1",
|
||||||
@@ -1309,13 +1284,15 @@ func TestRunContextImageOS(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
|
t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
|
||||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||||
rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" }
|
rc.Config.Platforms = map[string]string{
|
||||||
|
"ubuntu-latest": "docker.gitea.com/runner-images:ubuntu-24.04",
|
||||||
|
}
|
||||||
assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
|
assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("falls back to the runs-on label", func(t *testing.T) {
|
t.Run("falls back to the runs-on label", func(t *testing.T) {
|
||||||
rc := createRunsOnRunContext(t, "ubuntu-22.04")
|
rc := createRunsOnRunContext(t, "ubuntu-22.04")
|
||||||
rc.Config.PlatformPicker = func([]string) string { return "some-image" }
|
rc.Config.Platforms = map[string]string{"ubuntu-22.04": "some-image"}
|
||||||
assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
|
assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user