mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 14:55:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a8ee4ce57 | ||
|
|
bf03f36d5a | ||
|
|
5a38a3d0c4 | ||
|
|
6618e67645 | ||
|
|
2b599fae7f | ||
|
|
990ebaa2ac | ||
|
|
81add27459 | ||
|
|
ace8653105 | ||
|
|
95daa50d9b | ||
|
|
1952e5cdb6 | ||
|
|
7ad12038d1 | ||
|
|
98816a605c | ||
|
|
e0d00e543d | ||
|
|
ae9ce10f71 | ||
|
|
3962d0d970 | ||
|
|
d83fa77de4 | ||
|
|
58ed5921f3 | ||
|
|
00ff71a879 | ||
|
|
80db5f61ac | ||
|
|
5310d8119a | ||
|
|
b9c6305dee | ||
|
|
699a97fc8c | ||
|
|
19afebc53f | ||
|
|
2cc3000369 | ||
|
|
3d116eb0c2 | ||
|
|
e0ce6776c7 | ||
|
|
69d5c22300 | ||
|
|
54978255f5 | ||
|
|
498282caaa | ||
|
|
2ed8cdb76e | ||
|
|
ff9965e940 | ||
|
|
ba4d3c5b4f | ||
|
|
9e3647395a | ||
|
|
c158ac5472 | ||
|
|
1745c7c841 | ||
|
|
7f53eca716 | ||
|
|
fca6b44c60 | ||
|
|
b9018aca31 | ||
|
|
6c77065295 | ||
|
|
235c035003 | ||
|
|
12dc9d26a2 | ||
|
|
d9f4d65545 | ||
|
|
212909db7b | ||
|
|
0712b2a7a1 | ||
|
|
34df4887af | ||
|
|
745a1e70e6 | ||
|
|
e30c2fed62 | ||
|
|
7b4356c746 | ||
|
|
e78123cee3 | ||
|
|
8260e2def2 | ||
|
|
b97c61aa14 | ||
|
|
546eca312e | ||
|
|
a0c4de79f7 | ||
|
|
dfe979e1d0 | ||
|
|
11ac12efa4 |
No files matched your search
@@ -74,10 +74,10 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
@@ -103,7 +103,7 @@ jobs:
|
|||||||
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
+39
-10
@@ -5,17 +5,18 @@ 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
|
||||||
@@ -28,9 +29,16 @@ 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:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
|
||||||
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # renovate: datasource=docker
|
||||||
run: |
|
run: |
|
||||||
for img in node:24-bookworm-slim nginx:alpine; do
|
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||||
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
|
for attempt in 1 2 3; do
|
||||||
|
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
|
||||||
@@ -45,7 +53,28 @@ jobs:
|
|||||||
# after `make test` so the images it needs are already present on the host daemon.
|
# after `make test` so the images it needs are already present on the host daemon.
|
||||||
- name: test against dind image
|
- name: test against dind image
|
||||||
run: make test-dind
|
run: make test-dind
|
||||||
|
- name: test against podman
|
||||||
|
run: make test-dind TARGET=podman
|
||||||
- name: coverage report
|
- name: coverage report
|
||||||
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 }}
|
||||||
+8
-6
@@ -46,8 +46,7 @@ linters:
|
|||||||
gocritic:
|
gocritic:
|
||||||
enabled-checks:
|
enabled-checks:
|
||||||
- equalFold
|
- equalFold
|
||||||
disabled-checks:
|
disabled-checks: []
|
||||||
- ifElseChain
|
|
||||||
revive:
|
revive:
|
||||||
severity: error
|
severity: error
|
||||||
rules:
|
rules:
|
||||||
@@ -71,10 +70,14 @@ 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
|
||||||
- -ST1005
|
testifylint: {}
|
||||||
usetesting:
|
usetesting:
|
||||||
os-temp-dir: true
|
os-temp-dir: true
|
||||||
perfsprint:
|
perfsprint:
|
||||||
@@ -92,8 +95,6 @@ linters:
|
|||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
- comments
|
- comments
|
||||||
- common-false-positives
|
|
||||||
- legacy
|
|
||||||
- std-error-handling
|
- std-error-handling
|
||||||
rules:
|
rules:
|
||||||
- linters:
|
- linters:
|
||||||
@@ -118,7 +119,8 @@ formatters:
|
|||||||
- blank
|
- blank
|
||||||
- default
|
- default
|
||||||
gofumpt:
|
gofumpt:
|
||||||
extra-rules: true
|
extra:
|
||||||
|
group-params: true
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
run:
|
run:
|
||||||
|
|||||||
@@ -30,3 +30,17 @@ 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.
|
||||||
|
|
||||||
|
## Built-in actions
|
||||||
|
|
||||||
|
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
|
||||||
|
`act/runner/step_action_builtin.go`.
|
||||||
|
|
||||||
|
## End-to-end compatibility tests
|
||||||
|
|
||||||
|
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
||||||
|
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
|
||||||
|
parallel.
|
||||||
|
|
||||||
|
The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated
|
||||||
|
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
|
||||||
+6
-3
@@ -1,7 +1,7 @@
|
|||||||
### BUILDER STAGE
|
### BUILDER STAGE
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM golang:1.26-alpine3.23 AS builder
|
FROM golang:1.27-alpine3.23 AS builder
|
||||||
|
|
||||||
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
# 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.1-dind AS dind
|
FROM docker:29.7.2-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -32,12 +32,14 @@ COPY scripts/s6 /etc/s6
|
|||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|
||||||
|
ENV TINI_SUBREAPER=1
|
||||||
|
|
||||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||||
|
|
||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.7.1-dind-rootless AS dind-rootless
|
FROM docker:29.7.2-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ VOLUME /data
|
|||||||
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
|
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
|
||||||
|
|
||||||
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
|
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
|
||||||
|
ENV TINI_SUBREAPER=1
|
||||||
|
|
||||||
USER rootless
|
USER rootless
|
||||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ 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/[email protected] # renovate: datasource=go
|
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
|
||||||
XGO_VERSION := go-1.26.x
|
XGO_VERSION := go-1.27.x
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]6 # renovate: datasource=go
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]7 # renovate: datasource=go
|
||||||
|
|
||||||
|
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
|
||||||
|
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # renovate: datasource=docker
|
||||||
|
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||||
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
|
|
||||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||||
@@ -18,8 +23,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/[email protected]2.2 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]3.2 # renovate: datasource=go
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.6.0 # renovate: datasource=go
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go
|
||||||
|
|
||||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
@@ -96,13 +101,8 @@ go-check:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: fmt-check
|
.PHONY: fmt-check
|
||||||
fmt-check: fmt
|
fmt-check: ## check the Go code formatting
|
||||||
@diff=$$(git diff --color=always -- '*.go'); \
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make fmt' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: deps-tools
|
.PHONY: deps-tools
|
||||||
deps-tools: ## install tool dependencies
|
deps-tools: ## install tool dependencies
|
||||||
@@ -137,7 +137,7 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
|||||||
|
|
||||||
.PHONY: security-check
|
.PHONY: security-check
|
||||||
security-check:
|
security-check:
|
||||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
$(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
tidy: ## run go mod tidy
|
tidy: ## run go mod tidy
|
||||||
@@ -149,13 +149,8 @@ tidy: ## run go mod tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: tidy-check
|
.PHONY: tidy-check
|
||||||
tidy-check: tidy
|
tidy-check: ## check that go.mod and go.sum are tidy
|
||||||
@diff=$$(git diff --color=always -- go.mod go.sum); \
|
$(GO) mod tidy -diff
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make tidy' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## test everything (integration tests self-skip without docker/network)
|
test: ## test everything (integration tests self-skip without docker/network)
|
||||||
@@ -168,8 +163,14 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
|||||||
@echo "Wrote .tmp/coverage.md"
|
@echo "Wrote .tmp/coverage.md"
|
||||||
|
|
||||||
.PHONY: test-dind
|
.PHONY: test-dind
|
||||||
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 or podman (TARGET=dind|dind-rootless|podman)
|
||||||
@./scripts/test-dind.sh $(TARGET)
|
@PODMAN_TEST_IMAGE=$(PODMAN_TEST_IMAGE) ./scripts/test-dind.sh $(TARGET)
|
||||||
|
|
||||||
|
E2E_CONCURRENCY ?= 8
|
||||||
|
|
||||||
|
.PHONY: test-e2e
|
||||||
|
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
||||||
|
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_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`
|
||||||
|
|||||||
@@ -228,7 +228,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, 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).
|
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).
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -266,6 +266,17 @@ A job in a container reaches a service by its id on the job network, on the port
|
|||||||
|
|
||||||
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
|
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
|
||||||
|
|
||||||
|
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
|
||||||
|
|
||||||
|
Containers a job starts through the Docker socket can bind-mount the workspace by the job's own path, as on a host, for example `docker run -v "$PWD:/src"`. Without the Docker proxy, for example with a remote `DOCKER_HOST`, use `GITEA_DOCKER_WORKSPACE`, the path the daemon sees, as the prefix of workspace binds, with `.` as the fallback for local use:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. The proxy removes containers, networks and volumes after post steps and the completed hook. Named volumes created through it are job-scoped. To retain resources, mount the daemon socket explicitly in `container.options`. Host jobs use their existing Docker access, where `.` works.
|
||||||
|
|
||||||
#### Proxy
|
#### Proxy
|
||||||
|
|
||||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||||
@@ -280,14 +291,14 @@ The runner uses them for its own requests and gives them to every job, in lower
|
|||||||
|
|
||||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||||
|
|
||||||
- the cache server
|
- the built-in cache server, whose address is assigned at startup
|
||||||
- `localhost`, `127.0.0.1` and `::1`
|
- `localhost`, `127.0.0.1` and `::1`
|
||||||
- the job's service containers
|
- the job's service containers
|
||||||
- the Docker daemon, when it is reached over `tcp://`
|
- the Docker daemon, when it is reached over `tcp://`
|
||||||
|
|
||||||
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
||||||
|
|
||||||
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
For one step, set it in the step's `env:`, for the rest of a job, write it to `$GITHUB_ENV`. Setting it at workflow or job level or in `container.env` has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||||
|
|
||||||
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
||||||
|
|
||||||
@@ -295,63 +306,68 @@ Dockerfile actions are built with these variables as build arguments, so their `
|
|||||||
|
|
||||||
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
||||||
|
|
||||||
|
#### Built-in actions (`uses: builtin:<name>`)
|
||||||
|
|
||||||
|
Use `uses: builtin:checkout` to check out a repository without downloading `actions/checkout` or needing Node in the job image, see **[docs/builtin-actions.md](docs/builtin-actions.md)**.
|
||||||
|
|
||||||
#### Caching (`actions/cache`)
|
#### Caching (`actions/cache`)
|
||||||
|
|
||||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
Each runner starts its own cache server, so runners do not share cached entries. A runner in a container on the jobs' Docker daemon attaches itself to each job network, so jobs reach its cache without extra setup.
|
||||||
|
|
||||||
**Eviction**
|
**Sharing a cache between runners**
|
||||||
|
|
||||||
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.
|
Run one `gitea-runner cache-server` and point every runner at it, using the same secret everywhere, for example from `openssl rand -hex 32`:
|
||||||
|
|
||||||
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.
|
```yaml
|
||||||
|
# cache-server.yaml
|
||||||
|
cache:
|
||||||
|
dir: /data/actcache
|
||||||
|
port: 8088
|
||||||
|
external_secret: "<secret>" # or external_secret_file: /path/to/secret
|
||||||
|
```
|
||||||
|
|
||||||
**Cache service v2**
|
```bash
|
||||||
|
gitea-runner -c cache-server.yaml cache-server
|
||||||
|
```
|
||||||
|
|
||||||
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
```yaml
|
||||||
|
# each runner's config
|
||||||
|
cache:
|
||||||
|
external_server: "http://<cache-server-host>:8088/"
|
||||||
|
external_secret: "<secret>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--dir`, `--host` and `--port` override the matching `cache` keys. Eviction settings take effect on the cache server, not on the runners.
|
||||||
|
|
||||||
|
Runners can also share one `cache.dir` on a file system with working file locks, at the cost of slower cache requests.
|
||||||
|
|
||||||
|
**Storing the cache in an S3-compatible bucket**
|
||||||
|
|
||||||
|
The cache can also be stored in an S3-compatible bucket such as AWS S3, MinIO or RustFS, with no cache server. Runners with the same settings use the same cache:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cache:
|
cache:
|
||||||
v2: false
|
s3:
|
||||||
|
endpoint: "http://minio:9000"
|
||||||
|
region: "us-east-1"
|
||||||
|
bucket: "runner-cache"
|
||||||
|
prefix: "gitea-runner-cache"
|
||||||
|
path_style: true
|
||||||
|
access_key_file: "/run/secrets/cache-s3-access-key"
|
||||||
|
secret_key_file: "/run/secrets/cache-s3-secret-key"
|
||||||
```
|
```
|
||||||
|
|
||||||
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle and keeps the untouched copy beside it; a bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork.
|
A cache saved on one runner can be restored on the others within 30 seconds. The eviction settings below do not apply to the bucket, so add lifecycle rules to it that expire old caches and abort incomplete multipart uploads.
|
||||||
|
|
||||||
**Shared cache across multiple runners**
|
**Eviction**
|
||||||
|
|
||||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
Entries not read or written within `retention` (default `168h`) are removed. A repository over `repo_size_limit` (default `10GB`) loses its least recently used entries, and `size_limit` (off by default) caps the whole cache the same way. Entries in use are never removed. The cache also keeps 1024 MiB free on its volume, or `health_check.min_free_disk_space_mb` when health checks are enabled. See [config.example.yaml](internal/pkg/config/config.example.yaml) for all options.
|
||||||
|
|
||||||
1. Create a config file for the cache server host:
|
**Cache service v2**
|
||||||
|
|
||||||
```yaml
|
`actions/cache` v3.4.0, v4.2.0 and later use the cache service v2 API, which the runner serves by default. These actions fall back to v1 on hosts they do not recognize as GitHub, so the runner removes that check from them while a job runs. This also lets the stock `actions/upload-artifact` v4.4.0 and `actions/download-artifact` v4.1.8 and later work without the `gitea-upload-artifact` fork. With `runner.patch_actions: false`, the cache stays on v1 and the artifact actions fail.
|
||||||
cache:
|
|
||||||
dir: /data/actcache
|
|
||||||
port: 8088
|
|
||||||
external_secret: "replace-with-a-strong-random-secret"
|
|
||||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the server:
|
With v2, artifact calls also go through the cache server, so jobs must be able to reach it. `cache.v2: false` sends them to Gitea directly, unless the Gitea URL has a path or `runner.insecure` is set with HTTPS.
|
||||||
|
|
||||||
```bash
|
|
||||||
gitea-runner -c cache-server-config.yaml cache-server
|
|
||||||
```
|
|
||||||
|
|
||||||
3. On every runner:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
cache:
|
|
||||||
external_server: "http://<cache-server-host>:8088/"
|
|
||||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
|
||||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
|
||||||
|
|
||||||
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
|
|
||||||
|
|
||||||
#### Official Docker image
|
#### Official Docker image
|
||||||
|
|
||||||
@@ -386,6 +402,20 @@ Both hooks are synchronous and block the job while they run. Either one exiting
|
|||||||
|
|
||||||
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||||
|
|
||||||
|
#### OpenTelemetry (`OTEL_*`)
|
||||||
|
|
||||||
|
Set `OTEL_EXPORTER_OTLP_ENDPOINT` in the runner's environment to send a trace of every job to an OpenTelemetry collector. See **[docs/telemetry.md](docs/telemetry.md)**.
|
||||||
|
|
||||||
|
#### Local job logs (`log.job.dir`)
|
||||||
|
|
||||||
|
Set `log.job.dir` to a path and the runner writes a copy of every task's log there as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same secrets masked and the job's result on the last line. Off by default, and what Gitea shows does not change.
|
||||||
|
|
||||||
|
#### Secret masking
|
||||||
|
|
||||||
|
A job's secrets and its `::add-mask::` values are hidden from what the runner writes and uploads: the job log, the local copy above, job summaries, and the names of the containers it creates. A job output carrying one is skipped with a warning rather than sent masked, as GitHub does, so a downstream `needs.<job>.outputs.<name>` reading it is empty.
|
||||||
|
|
||||||
|
`log.job.retention` (default `168h`) is how long a log is kept, expired ones being deleted as new tasks start, and `log.job.max_size` (default `1GB`) caps one log. Keep `retention` above `runner.timeout` so a long job cannot outlive its own log, and prefer local disk, the file is written while the job runs. Only the runner's own user can read it.
|
||||||
|
|
||||||
### Example Deployments
|
### Example Deployments
|
||||||
|
|
||||||
Check out the [examples](examples) directory for sample deployment types.
|
Check out the [examples](examples) directory for sample deployment types.
|
||||||
+239
-57
@@ -10,17 +10,18 @@ import (
|
|||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -58,10 +59,9 @@ type JobCredential struct {
|
|||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
|
|
||||||
// Results is the instance whose artifact service this server forwards for the job, and
|
// Results is the instance whose artifact service this server forwards for the job, and
|
||||||
// InsecureTLS how the runner reaches it; see results.go. The tags are the wire format a
|
// InsecureTLS how the runner reaches it; see results.go.
|
||||||
// remote runner registers with.
|
Results string `json:"-"`
|
||||||
Results string `json:"results"`
|
InsecureTLS bool `json:"-"`
|
||||||
InsecureTLS bool `json:"insecure_tls"`
|
|
||||||
|
|
||||||
// PublicURL is this server as a reverse proxy makes the job reach it, not the listen address.
|
// PublicURL is this server as a reverse proxy makes the job reach it, not the listen address.
|
||||||
PublicURL string `json:"public_url"`
|
PublicURL string `json:"public_url"`
|
||||||
@@ -79,7 +79,8 @@ type credEntry struct {
|
|||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
dir string
|
dir string
|
||||||
storage *Storage
|
storage cacheStorage
|
||||||
|
s3 *s3Storage
|
||||||
router *httprouter.Router
|
router *httprouter.Router
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
port int
|
port int
|
||||||
@@ -89,6 +90,9 @@ type Handler struct {
|
|||||||
gcing atomic.Bool
|
gcing atomic.Bool
|
||||||
gcAt time.Time
|
gcAt time.Time
|
||||||
|
|
||||||
|
stopSync context.CancelFunc
|
||||||
|
syncing sync.WaitGroup
|
||||||
|
|
||||||
outboundIP string
|
outboundIP string
|
||||||
|
|
||||||
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
||||||
@@ -118,6 +122,8 @@ type Options struct {
|
|||||||
Dir string
|
Dir string
|
||||||
OutboundIP string
|
OutboundIP string
|
||||||
Port uint16
|
Port uint16
|
||||||
|
S3 *S3Options
|
||||||
|
Upstream string
|
||||||
|
|
||||||
// InternalSecret, when non-empty, enables a control-plane API at
|
// InternalSecret, when non-empty, enables a control-plane API at
|
||||||
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
||||||
@@ -130,7 +136,7 @@ type Options struct {
|
|||||||
Logger logrus.FieldLogger
|
Logger logrus.FieldLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartHandler opens the on-disk cache store and starts the HTTP server.
|
// StartHandler starts an embedded cache or forwards cache requests to Upstream.
|
||||||
func StartHandler(opts Options) (*Handler, error) {
|
func StartHandler(opts Options) (*Handler, error) {
|
||||||
dir, logger := opts.Dir, opts.Logger
|
dir, logger := opts.Dir, opts.Logger
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
@@ -148,6 +154,25 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
logger = logger.WithField("module", "artifactcache")
|
logger = logger.WithField("module", "artifactcache")
|
||||||
h.logger = logger
|
h.logger = logger
|
||||||
|
|
||||||
|
if opts.OutboundIP != "" {
|
||||||
|
h.outboundIP = opts.OutboundIP
|
||||||
|
} else if ip := common.GetOutboundIP(); ip == nil {
|
||||||
|
return nil, errors.New("unable to determine outbound IP address")
|
||||||
|
} else {
|
||||||
|
h.outboundIP = ip.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Upstream != "" {
|
||||||
|
handler, err := h.cacheForwarder(opts.Upstream)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := h.serve(opts.Port, handler); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -166,13 +191,11 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
h.storage = storage
|
h.storage = storage
|
||||||
|
if opts.S3 != nil {
|
||||||
if opts.OutboundIP != "" {
|
if h.s3, err = newS3Storage(storage, *opts.S3); err != nil {
|
||||||
h.outboundIP = opts.OutboundIP
|
return nil, err
|
||||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
}
|
||||||
return nil, errors.New("unable to determine outbound IP address")
|
h.storage = h.s3
|
||||||
} else {
|
|
||||||
h.outboundIP = ip.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret, err := loadOrCreateSecret(dir)
|
secret, err := loadOrCreateSecret(dir)
|
||||||
@@ -202,39 +225,79 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
|
|
||||||
h.gcCache()
|
h.gcCache()
|
||||||
|
|
||||||
|
if err := h.serve(opts.Port, router); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if h.s3 != nil {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
h.stopSync = cancel
|
||||||
|
h.syncing.Go(func() { h.syncMetadata(ctx) })
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serve(port uint16, handler http.Handler) error {
|
||||||
// Listen on all interfaces. Binding to outboundIP only would give no real
|
// Listen on all interfaces. Binding to outboundIP only would give no real
|
||||||
// security benefit (it is the LAN/internet-facing address either way) and
|
// security benefit (it is the LAN/internet-facing address either way) and
|
||||||
// can break Docker Desktop variants where the host's outbound IP is not
|
// 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 err
|
||||||
}
|
}
|
||||||
addr, ok := listener.Addr().(*net.TCPAddr)
|
addr, ok := listener.Addr().(*net.TCPAddr)
|
||||||
if !ok {
|
if !ok {
|
||||||
listener.Close()
|
listener.Close()
|
||||||
return nil, fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr())
|
return fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr())
|
||||||
}
|
}
|
||||||
h.port = addr.Port
|
h.port = addr.Port
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
ReadHeaderTimeout: 2 * time.Second,
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
Handler: router,
|
Handler: handler,
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
|
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
|
||||||
logger.Errorf("http serve: %v", err)
|
h.logger.Errorf("http serve: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
h.listener = listener
|
h.listener = listener
|
||||||
h.server = server
|
h.server = server
|
||||||
|
|
||||||
return h, nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) cacheForwarder(upstream string) (http.Handler, error) {
|
||||||
|
target, err := url.Parse(strings.TrimRight(upstream, "/"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse cache upstream: %w", err)
|
||||||
|
}
|
||||||
|
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||||
|
transport = transport.Clone()
|
||||||
|
transport.MaxIdleConnsPerHost = 64 // parallel block uploads would otherwise redial past the default 2
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Transport: transport,
|
||||||
|
Rewrite: func(r *httputil.ProxyRequest) {
|
||||||
|
r.SetURL(target)
|
||||||
|
r.Out.URL.RawQuery = r.In.URL.RawQuery // Rewrite drops unparsable query params, and signed URLs must pass verbatim.
|
||||||
|
},
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
h.logger.Warnf("cache forward %s %s: %v", r.Method, r.URL.Path, err)
|
||||||
|
h.twirpError(w, r, twirpUnavailable, errors.New("cache upstream is unavailable"))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, apiPath+"/") || strings.HasPrefix(r.URL.Path, cacheServiceV2Path+"/") {
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
h.forwardOrNotFound(w, r)
|
||||||
|
}
|
||||||
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ExternalURL() string {
|
func (h *Handler) ExternalURL() string {
|
||||||
// TODO: make the external url configurable if necessary
|
// TODO: make the external url configurable if necessary
|
||||||
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) baseURL(cred JobCredential) string {
|
func (h *Handler) baseURL(cred JobCredential) string {
|
||||||
@@ -334,6 +397,10 @@ func (h *Handler) Close() error {
|
|||||||
if h == nil {
|
if h == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if h.stopSync != nil {
|
||||||
|
h.stopSync()
|
||||||
|
h.syncing.Wait()
|
||||||
|
}
|
||||||
var retErr error
|
var retErr error
|
||||||
if h.server != nil {
|
if h.server != nil {
|
||||||
err := h.server.Close()
|
err := h.server.Close()
|
||||||
@@ -357,8 +424,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: json.Marshal,
|
Encoder: func(value any) ([]byte, error) { return json.Marshal(value) },
|
||||||
Decoder: json.Unmarshal,
|
Decoder: func(data []byte, value any) error { return json.Unmarshal(data, value) },
|
||||||
Options: &bbolt.Options{
|
Options: &bbolt.Options{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
NoGrowSync: bbolt.DefaultOptions.NoGrowSync,
|
NoGrowSync: bbolt.DefaultOptions.NoGrowSync,
|
||||||
@@ -367,6 +434,72 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncMetadata mirrors the bucket into the store until ctx ends, so no request waits on S3 for it.
|
||||||
|
func (h *Handler) syncMetadata(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(metadataSyncInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
if err := h.importMetadata(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
h.logger.Warnf("sync S3 cache: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// importMetadata lists after snapshotting the store, so an entry committed meanwhile, published before it is stored, stays.
|
||||||
|
func (h *Handler) importMetadata(ctx context.Context) error {
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
local := h.caches(db, bolthold.Where("Complete").Eq(true).Index("Complete"))
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
published, err := h.s3.published(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var gone []*Cache
|
||||||
|
for _, cache := range local {
|
||||||
|
if !published[cache.ID] {
|
||||||
|
gone = append(gone, cache)
|
||||||
|
}
|
||||||
|
delete(published, cache.ID)
|
||||||
|
}
|
||||||
|
added := make([]*Cache, 0, len(published))
|
||||||
|
for id := range published {
|
||||||
|
cache, err := h.s3.fetch(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
added = append(added, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err = h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
return db.Bolt().Update(func(tx *bbolt.Tx) error {
|
||||||
|
for _, cache := range added {
|
||||||
|
if err := db.TxUpsert(tx, cache.ID, cache); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, cache := range gone {
|
||||||
|
_ = h.s3.Storage.Remove(cache.ID) // a blob kept on disk before the runner used S3
|
||||||
|
if err := db.TxDelete(tx, cache.ID, cache); err != nil && !errors.Is(err, bolthold.ErrNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GET /_apis/artifactcache/cache
|
// GET /_apis/artifactcache/cache
|
||||||
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
@@ -409,7 +542,7 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
_ = db.Delete(cache.ID, cache)
|
h.deleteCache(db, 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
|
// Handing out a download URL counts as access, or eviction could drop the entry between
|
||||||
@@ -422,13 +555,15 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
|
|||||||
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
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.NewDecoder(r.Body).Decode(api); err != nil {
|
if err := json.UnmarshalRead(r.Body, api); err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := api.ToCache()
|
cache := &Cache{Repo: cred.Repo, Key: api.Key, Version: api.Version, Size: api.Size}
|
||||||
cache.Repo = cred.Repo
|
if cache.Size == 0 {
|
||||||
|
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)
|
||||||
@@ -439,7 +574,7 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
cache.CreatedAt = now
|
cache.CreatedAt = now
|
||||||
cache.UsedAt = now
|
cache.UsedAt = now
|
||||||
if err := insertCache(db, cache); err != nil {
|
if err := h.insertCache(db, cache); err != nil {
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -553,6 +688,12 @@ func (h *Handler) commitCache(cache *Cache) error {
|
|||||||
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
|
cache.UsedAt = time.Now().Unix() // a just-written entry counts as accessed, so it cannot be its own eviction victim
|
||||||
|
if h.s3 != nil {
|
||||||
|
if err := h.s3.publish(cache); err != nil {
|
||||||
|
_ = h.storage.Remove(cache.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db, err := h.openDB()
|
db, err := h.openDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -562,6 +703,9 @@ func (h *Handler) commitCache(cache *Cache) error {
|
|||||||
if err := db.Update(cache.ID, cache); err != nil {
|
if err := db.Update(cache.ID, cache); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if h.s3 != nil {
|
||||||
|
return nil // lifecycle rules on the bucket bound its size
|
||||||
|
}
|
||||||
// A commit is the only thing that grows the store, so the only thing that can push the
|
// A commit is the only thing that grows the store, so the only thing that can push the
|
||||||
// volume under the floor.
|
// volume under the floor.
|
||||||
h.evictRepo(db, cache.Repo)
|
h.evictRepo(db, cache.Repo)
|
||||||
@@ -601,12 +745,12 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||||
token := bearerToken(r)
|
token := bearerToken(r)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
|
h.unauthorized(w, r, errors.New("missing bearer token"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cred, ok := h.lookupCredential(token)
|
cred, ok := h.lookupCredential(token)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
|
h.unauthorized(w, r, errors.New("unknown bearer token"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||||
@@ -615,6 +759,14 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
|
||||||
|
h.twirpError(w, r, twirpUnauthenticated, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, err)
|
||||||
|
}
|
||||||
|
|
||||||
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||||
@@ -690,7 +842,7 @@ func (h *Handler) ResultsURL(cred JobCredential) string {
|
|||||||
|
|
||||||
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.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -699,14 +851,13 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.RegisterJob(body.Token, body.JobCredential)
|
h.RegisterJob(body.Token, body.JobCredential)
|
||||||
// A server too old to forward answers without this, which is how the caller knows.
|
h.responseJSON(w, r, http.StatusOK)
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{"results_url": h.ResultsURL(body.JobCredential)})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -718,6 +869,12 @@ func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httpr
|
|||||||
h.responseJSON(w, r, http.StatusOK)
|
h.responseJSON(w, r, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashedToken fingerprints a job's bearer, so a reservation can tell its own retry from another job.
|
||||||
|
func hashedToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
func bearerToken(r *http.Request) string {
|
func bearerToken(r *http.Request) string {
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
const prefix = "Bearer "
|
const prefix = "Bearer "
|
||||||
@@ -768,14 +925,9 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
|||||||
if exact != nil {
|
if exact != nil {
|
||||||
return exact, nil
|
return exact, nil
|
||||||
}
|
}
|
||||||
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
|
||||||
re, err := regexp.Compile(prefixPattern)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := db.FindOne(cache,
|
if err := db.FindOne(cache,
|
||||||
bolthold.Where("Repo").Eq(repo).
|
bolthold.Where("Repo").Eq(repo).Index("Repo").
|
||||||
And("Key").RegExp(re).
|
And("Key").MatchFunc(func(key string) (bool, error) { return strings.HasPrefix(key, prefix), nil }).
|
||||||
And("Version").Eq(version).
|
And("Version").Eq(version).
|
||||||
And("Complete").Eq(true).
|
And("Complete").Eq(true).
|
||||||
SortBy("CreatedAt").Reverse()); err != nil {
|
SortBy("CreatedAt").Reverse()); err != nil {
|
||||||
@@ -803,8 +955,8 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
|||||||
}
|
}
|
||||||
cache := &Cache{}
|
cache := &Cache{}
|
||||||
err := db.FindOne(cache,
|
err := db.FindOne(cache,
|
||||||
bolthold.Where("Repo").Eq(repo).
|
bolthold.Where("Key").Eq(key).Index("Key").
|
||||||
And("Key").Eq(key).
|
And("Repo").Eq(repo).
|
||||||
And("Version").Eq(version).
|
And("Version").Eq(version).
|
||||||
And("Complete").Eq(complete).
|
And("Complete").Eq(complete).
|
||||||
SortBy(sortBy).Reverse())
|
SortBy(sortBy).Reverse())
|
||||||
@@ -817,15 +969,23 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
|||||||
return cache, nil
|
return cache, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertCache(db *bolthold.Store, cache *Cache) error {
|
func (h *Handler) insertCache(db *bolthold.Store, cache *Cache) error {
|
||||||
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
if h.s3 != nil {
|
||||||
return fmt.Errorf("insert cache: %w", err)
|
var id [8]byte
|
||||||
|
_, _ = rand.Read(id[:])
|
||||||
|
cache.ID = binary.BigEndian.Uint64(id[:]) >> 11 // IDs must be unique across runners, and v1 clients parse an ID as a JS number
|
||||||
|
return db.Insert(cache.ID, cache)
|
||||||
}
|
}
|
||||||
// write back id to db
|
return db.Bolt().Update(func(tx *bbolt.Tx) error {
|
||||||
if err := db.Update(cache.ID, cache); err != nil {
|
if err := db.TxInsert(tx, bolthold.NextSequence(), cache); err != nil {
|
||||||
return fmt.Errorf("write back id to db: %w", err)
|
return fmt.Errorf("insert cache: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
// write back id to db
|
||||||
|
if err := db.TxUpdate(tx, cache.ID, cache); err != nil {
|
||||||
|
return fmt.Errorf("write back id to db: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||||
@@ -850,6 +1010,9 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
|||||||
if requireIncomplete && cache.Complete {
|
if requireIncomplete && cache.Complete {
|
||||||
return fmt.Errorf("cache %d: already complete", id)
|
return fmt.Errorf("cache %d: already complete", id)
|
||||||
}
|
}
|
||||||
|
if !touchNeeded(cache) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
cache.UsedAt = time.Now().Unix()
|
cache.UsedAt = time.Now().Unix()
|
||||||
return db.Update(cache.ID, cache)
|
return db.Update(cache.ID, cache)
|
||||||
}
|
}
|
||||||
@@ -859,15 +1022,19 @@ const (
|
|||||||
|
|
||||||
defaultSweepInterval = time.Hour
|
defaultSweepInterval = time.Hour
|
||||||
|
|
||||||
// inUseGrace matches artifactURLTTL so an entry outlives every signed URL still usable
|
touchStale = time.Minute // how stale a completed entry's UsedAt may get before an access rewrites it, sparing hits the fsync
|
||||||
// for it, and no sweep cuts off a download in progress.
|
|
||||||
inUseGrace = artifactURLTTL
|
// inUseGrace covers artifactURLTTL plus the touchStale lag so an entry outlives every signed URL
|
||||||
|
// still usable for it, and no sweep cuts off a download in progress.
|
||||||
|
inUseGrace = artifactURLTTL + touchStale
|
||||||
|
|
||||||
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts
|
// 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
|
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize
|
||||||
// a stale reservation.
|
// a stale reservation.
|
||||||
uploadStallTimeout = 5 * time.Minute
|
uploadStallTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
metadataSyncInterval = 30 * time.Second
|
||||||
|
|
||||||
defaultMinFreeDisk = 1024 * miB
|
defaultMinFreeDisk = 1024 * miB
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -915,6 +1082,9 @@ func (h *Handler) gcCache() {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
h.evictIncomplete(db)
|
h.evictIncomplete(db)
|
||||||
|
if h.s3 != nil {
|
||||||
|
return // lifecycle rules on the bucket expire its entries
|
||||||
|
}
|
||||||
h.evictExpired(db)
|
h.evictExpired(db)
|
||||||
h.evictSuperseded(db)
|
h.evictSuperseded(db)
|
||||||
h.evictOversized(db)
|
h.evictOversized(db)
|
||||||
@@ -955,7 +1125,7 @@ func (h *Handler) evictExpired(db *bolthold.Store) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
|
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
|
||||||
window := max(h.policy.Retention, inUseGrace)
|
window := max(h.policy.Retention+touchStale, inUseGrace)
|
||||||
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,10 +1226,18 @@ func inUse(cache *Cache) bool {
|
|||||||
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
|
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// touchNeeded skips a write that would not change the second, and completed entries fresher than touchStale.
|
||||||
|
func touchNeeded(cache *Cache) bool {
|
||||||
|
return cache.UsedAt < time.Now().Unix() && (!cache.Complete || time.Since(time.Unix(cache.UsedAt, 0)) >= touchStale)
|
||||||
|
}
|
||||||
|
|
||||||
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
|
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
|
||||||
// go through touchCache, which opens its own store and would block on the exclusive lock
|
// go through touchCache, which opens its own store and would block on the exclusive lock
|
||||||
// for as long as the caller holds one.
|
// for as long as the caller holds one.
|
||||||
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
|
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
|
||||||
|
if !touchNeeded(cache) {
|
||||||
|
return
|
||||||
|
}
|
||||||
cache.UsedAt = time.Now().Unix()
|
cache.UsedAt = time.Now().Unix()
|
||||||
if err := db.Update(cache.ID, cache); err != nil {
|
if err := db.Update(cache.ID, cache); err != nil {
|
||||||
h.logger.Warnf("touch cache: %v", err)
|
h.logger.Warnf("touch cache: %v", err)
|
||||||
@@ -1104,7 +1282,11 @@ func totalSize(caches []*Cache) int64 {
|
|||||||
// deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes
|
// deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes
|
||||||
// first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes.
|
// first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes.
|
||||||
func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool {
|
func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool {
|
||||||
if err := h.storage.Remove(cache.ID); err != nil {
|
storage := h.storage
|
||||||
|
if h.s3 != nil && !cache.Complete {
|
||||||
|
storage = h.s3.Storage // an upload not yet committed has nothing in the bucket
|
||||||
|
}
|
||||||
|
if err := storage.Remove(cache.ID); err != nil {
|
||||||
h.logger.Warnf("remove cache blob: %v", err)
|
h.logger.Warnf("remove cache blob: %v", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ package artifactcache
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -52,6 +53,59 @@ func signArtifactURL(h *Handler, id int64) string {
|
|||||||
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
|
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_ExternalURL(t *testing.T) {
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_Upstream(t *testing.T) {
|
||||||
|
var upstream *httptest.Server
|
||||||
|
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPut, r.Method)
|
||||||
|
assert.Equal(t, "/cache%20server"+blobPath+"/%34%32", r.URL.EscapedPath())
|
||||||
|
assert.Equal(t, "sig=opaque%2Fvalue&raw=a;b", r.URL.RawQuery)
|
||||||
|
assert.Equal(t, strings.TrimPrefix(upstream.URL, "http://"), r.Host)
|
||||||
|
assert.Equal(t, "Bearer unregistered", r.Header.Get("Authorization"))
|
||||||
|
_, err := io.Copy(w, r.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
|
handler, err := StartHandler(Options{
|
||||||
|
Dir: dir,
|
||||||
|
OutboundIP: "127.0.0.1",
|
||||||
|
Upstream: upstream.URL + "/cache%20server//",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, handler.Close()) })
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut,
|
||||||
|
handler.ExternalURL()+blobPath+"/%34%32?sig=opaque%2Fvalue&raw=a;b", strings.NewReader("cache content"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer unregistered")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
forwarded, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "cache content", string(forwarded))
|
||||||
|
|
||||||
|
resp, err = http.Post(handler.ExternalURL()+internalPath+"/register", "application/json", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
|
||||||
|
upstream.Close()
|
||||||
|
resp, err = http.Post(handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", "application/json", strings.NewReader(`{}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var failure map[string]string
|
||||||
|
err = json.UnmarshalRead(resp.Body, &failure)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||||
|
assert.Equal(t, map[string]string{"code": "unavailable", "msg": "cache upstream is unavailable"}, failure)
|
||||||
|
assert.NoDirExists(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandler(t *testing.T) {
|
func TestHandler(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
@@ -136,7 +190,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.NewDecoder(resp.Body).Decode(&first))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &first))
|
||||||
assert.NotZero(t, first.CacheID)
|
assert.NotZero(t, first.CacheID)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -151,7 +205,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.NewDecoder(resp.Body).Decode(&second))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &second))
|
||||||
assert.NotZero(t, second.CacheID)
|
assert.NotZero(t, second.CacheID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +258,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -259,7 +313,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -315,7 +369,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -362,7 +416,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +467,7 @@ func TestHandler(t *testing.T) {
|
|||||||
got := struct {
|
got := struct {
|
||||||
CacheID uint64 `json:"cacheId"`
|
CacheID uint64 `json:"cacheId"`
|
||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -493,7 +547,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.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &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 +582,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.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &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 +631,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.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &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 +687,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.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &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)
|
||||||
@@ -677,7 +731,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.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
id = got.CacheID
|
id = got.CacheID
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -708,7 +762,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.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &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
|
||||||
@@ -767,7 +821,7 @@ 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(-(testRetention + touchStale + time.Second)).Unix(),
|
||||||
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: false,
|
||||||
@@ -810,7 +864,7 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
db, err := handler.openDB()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
require.NoError(t, insertCache(db, c.Cache))
|
require.NoError(t, handler.insertCache(db, c.Cache))
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
@@ -982,7 +1036,7 @@ func TestHandler_SweepKeepsEntryWhenBlobSurvives(t *testing.T) {
|
|||||||
handler := newTestHandler(t, Policy{Retention: testRetention}, cache)
|
handler := newTestHandler(t, Policy{Retention: testRetention}, cache)
|
||||||
|
|
||||||
// A non-empty directory where the blob belongs makes os.Remove fail on every platform.
|
// A non-empty directory where the blob belongs makes os.Remove fail on every platform.
|
||||||
blob := handler.storage.filename(cache.ID)
|
blob := handler.storage.(*Storage).filename(cache.ID)
|
||||||
require.NoError(t, os.MkdirAll(blob, 0o755))
|
require.NoError(t, os.MkdirAll(blob, 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600))
|
require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600))
|
||||||
|
|
||||||
@@ -1042,27 +1096,51 @@ func TestHandler_gcCacheInterval(t *testing.T) {
|
|||||||
assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
|
assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
|
||||||
|
fresh := &Cache{Repo: testRepo, Key: "fresh", Version: "v", Complete: true, UsedAt: time.Now().Unix()}
|
||||||
|
pending := &Cache{Repo: testRepo, Key: "pending", Version: "v", UsedAt: time.Now().Add(-time.Second).Unix()}
|
||||||
|
handler := newTestHandler(t, Policy{}, fresh, pending)
|
||||||
|
txID := func() int {
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
var id int
|
||||||
|
require.NoError(t, db.Bolt().View(func(tx *bbolt.Tx) error { id = tx.ID(); return nil }))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
before := txID()
|
||||||
|
require.NoError(t, handler.touchCache(fresh.ID, false))
|
||||||
|
assert.Equal(t, before, txID())
|
||||||
|
require.NoError(t, handler.touchCache(pending.ID, false))
|
||||||
|
assert.Greater(t, txID(), before)
|
||||||
|
}
|
||||||
|
|
||||||
// newTestHandler starts a handler with testToken registered, seeded with entries.
|
// newTestHandler starts a handler with testToken registered, seeded with entries.
|
||||||
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
handler, err := StartHandler(Options{
|
handler := startTestHandler(t, Options{Policy: policy})
|
||||||
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()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
require.NoError(t, insertCache(db, e))
|
require.NoError(t, handler.insertCache(db, e))
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startTestHandler(t *testing.T, opts Options) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
opts.Dir = filepath.Join(t.TempDir(), "artifactcache")
|
||||||
|
opts.OutboundIP = "127.0.0.1"
|
||||||
|
handler, err := StartHandler(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, handler.Close()) })
|
||||||
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
// keptKeys reports which of entries are still in the store.
|
// keptKeys reports which of entries are still in the store.
|
||||||
func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string {
|
func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1197,7 +1275,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.NewDecoder(resp.Body).Decode(&reserved))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &reserved))
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
require.NotZero(t, reserved.CacheID)
|
require.NotZero(t, reserved.CacheID)
|
||||||
|
|
||||||
@@ -1331,7 +1409,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.NewDecoder(resp.Body).Decode(&hit))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &hit))
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
require.Contains(t, hit.ArchiveLocation, "sig=")
|
require.Contains(t, hit.ArchiveLocation, "sig=")
|
||||||
@@ -1403,8 +1481,8 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
|||||||
stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
|
stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
|
||||||
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
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, handler.insertCache(db, a))
|
||||||
require.NoError(t, insertCache(db, b))
|
require.NoError(t, handler.insertCache(db, b))
|
||||||
// Write the backing blobs so the dedup deletion has something to remove.
|
// Write the backing blobs so the dedup deletion has something to remove.
|
||||||
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
|
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
|
||||||
_, err = handler.storage.Commit(a.ID, 1)
|
_, err = handler.storage.Commit(a.ID, 1)
|
||||||
@@ -1498,7 +1576,10 @@ func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
||||||
assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo"}`))
|
assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo","public_url":"http://runner.example","results":"https://gitea.example","insecure_tls":true}`))
|
||||||
|
cred, ok := handler.lookupCredential("via-internal-api")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, JobCredential{Repo: testRepo, PublicURL: "http://runner.example"}, cred)
|
||||||
assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api"))
|
assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api"))
|
||||||
assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
|
assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
|
||||||
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ package artifactcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"encoding/json"
|
"encoding/json/jsontext"
|
||||||
|
"encoding/json/v2"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -38,8 +39,9 @@ const (
|
|||||||
|
|
||||||
blobUploadURLTTL = time.Hour
|
blobUploadURLTTL = time.Hour
|
||||||
|
|
||||||
// twirpInternal is the only error code that is not the client's fault.
|
twirpInternal = "internal"
|
||||||
twirpInternal = "internal"
|
twirpUnavailable = "unavailable"
|
||||||
|
twirpUnauthenticated = "unauthenticated"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||||
@@ -55,7 +57,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Key == "" || req.Version == "" {
|
if req.Key == "" || req.Version == "" {
|
||||||
@@ -82,16 +84,35 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A second live reservation is finalized by whichever job calls last, against the other's upload.
|
||||||
|
owner := hashedToken(bearerToken(r))
|
||||||
|
if pending, err := findExactCache(db, cred.Repo, req.Key, req.Version, false); err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
|
||||||
|
if pending.Owner != owner {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.touch(db, pending) // still uploading, so it must not go stale under the sweep
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{ // this job retrying its own reservation
|
||||||
|
"ok": true,
|
||||||
|
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, pending.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
cache := &Cache{
|
cache := &Cache{
|
||||||
Repo: cred.Repo,
|
Repo: cred.Repo,
|
||||||
Key: req.Key,
|
Key: req.Key,
|
||||||
Version: req.Version,
|
Version: req.Version,
|
||||||
|
Owner: owner,
|
||||||
Size: -1, // the size is only known at finalize time
|
Size: -1, // the size is only known at finalize time
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UsedAt: now,
|
UsedAt: now,
|
||||||
}
|
}
|
||||||
if err := insertCache(db, cache); err != nil {
|
if err := h.insertCache(db, cache); err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,7 +127,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +139,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||||
|
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
|
||||||
|
cache = nil // not the reservation this job made, so not this job's to commit
|
||||||
|
}
|
||||||
|
if err == nil && cache == nil {
|
||||||
|
// A retry whose first response was lost finds the entry already committed.
|
||||||
|
cache, err = findExactCache(db, cred.Repo, req.Key, req.Version, true)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
@@ -126,13 +154,15 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
h.twirpNotOK(w, r)
|
h.twirpNotOK(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.Close() // commitCache needs the store closed
|
|
||||||
|
|
||||||
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
if !cache.Complete {
|
||||||
if err := h.commitCache(cache); err != nil {
|
db.Close() // commitCache needs the store closed
|
||||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
|
||||||
h.twirpNotOK(w, r)
|
if err := h.commitCache(cache); err != nil {
|
||||||
return
|
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
@@ -146,7 +176,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +259,13 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||||
status := http.StatusBadRequest
|
status := http.StatusBadRequest
|
||||||
if code == twirpInternal {
|
switch code {
|
||||||
|
case twirpInternal:
|
||||||
status = http.StatusInternalServerError
|
status = http.StatusInternalServerError
|
||||||
|
case twirpUnavailable:
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
case twirpUnauthenticated:
|
||||||
|
status = http.StatusUnauthorized
|
||||||
}
|
}
|
||||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -245,10 +280,10 @@ type (
|
|||||||
}
|
}
|
||||||
|
|
||||||
v2FinalizeRequest struct {
|
v2FinalizeRequest struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
SizeBytes json.Number `json:"size_bytes"`
|
SizeBytes twirpInt64 `json:"size_bytes"`
|
||||||
SizeBytesCamel json.Number `json:"sizeBytes"`
|
SizeBytesCamel twirpInt64 `json:"sizeBytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
v2DownloadRequest struct {
|
v2DownloadRequest struct {
|
||||||
@@ -259,6 +294,31 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// twirpInt64 accepts its value as the JSON string the mapping prescribes or as a bare number.
|
||||||
|
type twirpInt64 int64
|
||||||
|
|
||||||
|
func (n *twirpInt64) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
||||||
|
val, err := dec.ReadValue()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
digits := []byte(val)
|
||||||
|
switch val.Kind() {
|
||||||
|
case 'n': // absent, keep the zero value
|
||||||
|
return nil
|
||||||
|
case '"':
|
||||||
|
if digits, err = jsontext.AppendUnquote(nil, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(string(digits), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*n = twirpInt64(parsed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d v2DownloadRequest) keys() []string {
|
func (d v2DownloadRequest) keys() []string {
|
||||||
restoreKeys := d.RestoreKeys
|
restoreKeys := d.RestoreKeys
|
||||||
if len(restoreKeys) == 0 {
|
if len(restoreKeys) == 0 {
|
||||||
@@ -269,6 +329,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.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
|
err := json.UnmarshalRead(io.LimitReader(r.Body, 1<<20), &req)
|
||||||
return req, err
|
return req, err
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,11 @@ package artifactcache
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -32,7 +33,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.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
return got
|
return got
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +142,17 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
list.WriteString(`</BlockList>`)
|
list.WriteString(`</BlockList>`)
|
||||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()),
|
||||||
|
"a client that lost the first answer retries the list it already sent")
|
||||||
|
|
||||||
|
var reordered bytes.Buffer
|
||||||
|
reordered.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||||
|
for _, blockID := range []string{order[1], order[0], order[2]} {
|
||||||
|
fmt.Fprintf(&reordered, "<Latest>%s</Latest>", blockID)
|
||||||
|
}
|
||||||
|
reordered.WriteString(`</BlockList>`)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, putBlob(t, uploadURL+"&comp=blocklist", reordered.Bytes()),
|
||||||
|
"the blocks are already assembled, so a different order would silently disagree with them")
|
||||||
|
|
||||||
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||||
@@ -227,7 +239,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.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &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"])
|
||||||
})
|
})
|
||||||
@@ -241,3 +253,101 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
assert.Equal(t, false, got["ok"])
|
assert.Equal(t, false, got["ok"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
|
||||||
|
handler := newTestHandler(t, Policy{})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
body string
|
||||||
|
status int
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"no bearer at all", "", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||||
|
{"a bearer nobody registered", "not-a-job", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||||
|
{"a body that is not the request", testToken, `{`, http.StatusBadRequest, "malformed"},
|
||||||
|
{"a request missing its key", testToken, `{"version":"v"}`, http.StatusBadRequest, "invalid_argument"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", strings.NewReader(tt.body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tt.token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, tt.status, resp.StatusCode)
|
||||||
|
got := map[string]string{}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
|
assert.Equal(t, tt.code, got["code"])
|
||||||
|
assert.NotEmpty(t, got["msg"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
|
||||||
|
handler := newTestHandler(t, Policy{})
|
||||||
|
content := []byte("the cached archive")
|
||||||
|
|
||||||
|
const otherJob = "another-jobs-token"
|
||||||
|
defer handler.RegisterJob(otherJob, JobCredential{Repo: testRepo})()
|
||||||
|
reserve := func(client *http.Client) map[string]any {
|
||||||
|
return v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "shared", "version": "v1"})
|
||||||
|
}
|
||||||
|
first := reserve(testClient)
|
||||||
|
require.Equal(t, true, first["ok"])
|
||||||
|
retry := reserve(testClient)
|
||||||
|
assert.Equal(t, true, retry["ok"], "a job retrying its own reservation is not a conflict")
|
||||||
|
entryOf := func(reserved map[string]any) string {
|
||||||
|
parsed, err := url.Parse(reserved["signed_upload_url"].(string))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return parsed.Path
|
||||||
|
}
|
||||||
|
assert.Equal(t, entryOf(first), entryOf(retry), "the same reservation, however freshly its URL is signed")
|
||||||
|
assert.Equal(t, false, reserve(&http.Client{Transport: &bearerTransport{token: otherJob}})["ok"],
|
||||||
|
"another job's reservation would be finalized against the first upload")
|
||||||
|
|
||||||
|
otherClient := &http.Client{Transport: &bearerTransport{token: otherJob}}
|
||||||
|
reserved := v2Call(t, handler, otherClient, "CreateCacheEntry", map[string]any{"key": "owned", "version": "v1"})
|
||||||
|
require.Equal(t, true, reserved["ok"])
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, reserved["signed_upload_url"].(string), content))
|
||||||
|
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||||
|
map[string]any{"key": "owned", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||||
|
"another job's upload is not this job's to finalize")
|
||||||
|
|
||||||
|
base := handler.ExternalURL() + apiPath
|
||||||
|
reserveV1, err := json.Marshal(&Request{Key: "v1-made", Version: "v1", Size: int64(len(content))})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(reserveV1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var madeByV1 struct {
|
||||||
|
CacheID uint64 `json:"cacheId"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &madeByV1))
|
||||||
|
resp.Body.Close()
|
||||||
|
upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
|
||||||
|
fmt.Sprintf("%s/caches/%d", base, madeByV1.CacheID), bytes.NewReader(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
|
||||||
|
resp, err = testClient.Do(upload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||||
|
map[string]any{"key": "v1-made", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||||
|
"a reservation this job did not make through v2 is not its to commit")
|
||||||
|
|
||||||
|
finalized, _ := saveV2(t, handler, "deps", "v1", content)
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
again := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "deps", "version": "v1", "size_bytes": strconv.Itoa(len(content)),
|
||||||
|
})
|
||||||
|
assert.Equal(t, true, again["ok"], "a retry whose first answer was lost must not report a failed save")
|
||||||
|
assert.Equal(t, finalized["entry_id"], again["entry_id"])
|
||||||
|
}
|
||||||
@@ -10,29 +10,13 @@ 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"`
|
||||||
Key string `json:"key" boltholdIndex:"Key"`
|
Key string `json:"key" boltholdIndex:"Key"`
|
||||||
Version string `json:"version" boltholdIndex:"Version"`
|
Version string `json:"version" boltholdIndex:"Version"`
|
||||||
Size int64 `json:"cacheSize"`
|
Size int64 `json:"cacheSize"`
|
||||||
|
Owner string `json:"owner"` // hashed bearer, so a job's own retry is not another job's reservation
|
||||||
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
||||||
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
||||||
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package artifactcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -15,23 +17,38 @@ import (
|
|||||||
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
|
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
|
||||||
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
|
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
|
||||||
// this runner cannot patch need, docker buildx among them.
|
// this runner cannot patch need, docker buildx among them.
|
||||||
//
|
|
||||||
// The instance to forward to travels with the job registration rather than with configuration, so
|
|
||||||
// a cache server shared between runners serves each of their instances.
|
|
||||||
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
|
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
|
||||||
|
|
||||||
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
||||||
// registered with, and the 404 the router would have written otherwise.
|
// registered with, and the 404 the router would have written otherwise.
|
||||||
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||||
cred, ok := h.lookupCredential(bearerToken(r))
|
if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||||
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cred, ok := h.lookupCredential(bearerToken(r))
|
||||||
|
if !ok {
|
||||||
|
h.twirpError(w, r, twirpUnauthenticated, errors.New("unknown bearer token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cred.Results == "" {
|
||||||
|
h.twirpError(w, r, twirpInternal, errors.New("no instance is registered for this job"))
|
||||||
|
return
|
||||||
|
}
|
||||||
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
||||||
|
if err == nil && (target.Hostname() == "" || (target.Scheme != "http" && target.Scheme != "https")) {
|
||||||
|
err = errors.New("not an absolute http or https URL the job could use")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
h.twirpError(w, r, twirpInternal, fmt.Errorf("artifact service address %q is unusable: %w", cred.Results, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.mustProxy(r, cred, target) {
|
||||||
|
redirect := target.JoinPath(r.URL.EscapedPath())
|
||||||
|
redirect.RawQuery = mergeQuery(target.RawQuery, r.URL.RawQuery)
|
||||||
|
h.logger.Debugf("%s %s: redirecting to %s", r.Method, r.URL.Path, target)
|
||||||
|
http.Redirect(w, r, redirect.String(), http.StatusTemporaryRedirect)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
||||||
@@ -44,9 +61,9 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
|||||||
// directly and never through here.
|
// directly and never through here.
|
||||||
r.Out.Host = target.Host
|
r.Out.Host = target.Host
|
||||||
},
|
},
|
||||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if cred.InsecureTLS {
|
if cred.InsecureTLS {
|
||||||
@@ -55,5 +72,24 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A protocol switch loses the bearer or the agent, and an untrusted instance needs skipped verification.
|
||||||
|
func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL) bool {
|
||||||
|
if cred.InsecureTLS && target.Scheme == "https" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
return scheme != target.Scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeQuery(target, request string) string {
|
||||||
|
if target == "" || request == "" {
|
||||||
|
return target + request
|
||||||
|
}
|
||||||
|
return target + "&" + request
|
||||||
|
}
|
||||||
|
|
||||||
// insecureTransport is shared, because a transport per request would pool no connections.
|
// insecureTransport is shared, because a transport per request would pool no connections.
|
||||||
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
var insecureTransport = &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"encoding/json/v2"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -14,8 +16,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
|
|
||||||
// still point at Gitea, and nothing else is proxied.
|
|
||||||
func TestFrontResultsService(t *testing.T) {
|
func TestFrontResultsService(t *testing.T) {
|
||||||
var gotHost, gotPath, gotProto string
|
var gotHost, gotPath, gotProto string
|
||||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -23,34 +23,112 @@ func TestFrontResultsService(t *testing.T) {
|
|||||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||||
}))
|
}))
|
||||||
defer gitea.Close()
|
defer gitea.Close()
|
||||||
|
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
|
||||||
|
defer selfSigned.Close()
|
||||||
|
|
||||||
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
const token = "forward-token"
|
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
|
||||||
|
|
||||||
client := &http.Client{Transport: &bearerTransport{token: token}}
|
noRedirect := func(token string) *http.Client {
|
||||||
post := func(path string) int {
|
return &http.Client{
|
||||||
|
Transport: &bearerTransport{token: token},
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post := func(path string) (int, map[string]string) {
|
||||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := client.Do(req)
|
resp, err := noRedirect(token).Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return resp.StatusCode
|
body := map[string]string{}
|
||||||
|
_ = json.UnmarshalRead(resp.Body, &body)
|
||||||
|
return resp.StatusCode, body
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
status, body := post(rpc)
|
||||||
"an unregistered token is forwarded nowhere")
|
assert.Equal(t, http.StatusUnauthorized, status, "an unregistered token is told so, not left to parse a 404 page")
|
||||||
|
assert.Equal(t, twirpUnauthenticated, body["code"])
|
||||||
|
assert.Contains(t, body["msg"], "bearer")
|
||||||
|
assert.NotEmpty(t, body["msg"])
|
||||||
|
|
||||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})()
|
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: selfSigned.URL, InsecureTLS: true})()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact"))
|
status, _ = post(rpc)
|
||||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
require.Equal(t, http.StatusOK, status, "an http job reaching an https instance must proxy")
|
||||||
|
assert.Equal(t, strings.TrimPrefix(selfSigned.URL, "https://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||||
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
||||||
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
assert.Equal(t, rpc, gotPath)
|
||||||
|
|
||||||
gotPath = ""
|
gotPath = ""
|
||||||
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do"))
|
status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
|
||||||
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo"))
|
assert.Equal(t, http.StatusNotFound, status)
|
||||||
|
status, _ = post("/api/v1/repos/owner/repo")
|
||||||
|
assert.Equal(t, http.StatusNotFound, status)
|
||||||
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cred JobCredential
|
||||||
|
forwardedProto string
|
||||||
|
reqQuery string
|
||||||
|
want int
|
||||||
|
location string
|
||||||
|
wantMsg string
|
||||||
|
}{
|
||||||
|
{name: "an instance the job reaches itself", cred: JobCredential{Results: gitea.URL}, want: http.StatusTemporaryRedirect},
|
||||||
|
{name: "a sub-path instance keeps its prefix", cred: JobCredential{Results: gitea.URL + "/sub"}, want: http.StatusTemporaryRedirect},
|
||||||
|
{name: "an instance with no certificate to distrust", cred: JobCredential{Results: gitea.URL, InsecureTLS: true}, want: http.StatusTemporaryRedirect},
|
||||||
|
{
|
||||||
|
name: "both queries survive", cred: JobCredential{Results: gitea.URL + "?tenant=one"}, reqQuery: "?run=7",
|
||||||
|
want: http.StatusTemporaryRedirect, location: gitea.URL + rpc + "?tenant=one&run=7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a trusted https instance is reached by the job itself",
|
||||||
|
cred: JobCredential{Results: selfSigned.URL}, forwardedProto: "https", want: http.StatusTemporaryRedirect,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the job does not share this server's disregard for the certificate",
|
||||||
|
cred: JobCredential{Results: selfSigned.URL, InsecureTLS: true}, forwardedProto: "https", want: http.StatusOK,
|
||||||
|
},
|
||||||
|
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
|
||||||
|
{
|
||||||
|
name: "a forwarded https scheme onto an http instance is proxied",
|
||||||
|
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a scheme the job could not use is refused before it is sent one",
|
||||||
|
cred: JobCredential{Results: "ftp://gitea.example"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an address with no host is refused before it is sent one",
|
||||||
|
cred: JobCredential{Results: "http://:3000"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
defer handler.RegisterJob(tt.name, tt.cred)()
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+rpc+tt.reqQuery, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.forwardedProto != "" {
|
||||||
|
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||||
|
}
|
||||||
|
resp, err := noRedirect(tt.name).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, tt.want, resp.StatusCode)
|
||||||
|
switch tt.want {
|
||||||
|
case http.StatusTemporaryRedirect:
|
||||||
|
assert.Equal(t, cmp.Or(tt.location, tt.cred.Results+rpc), resp.Header.Get("Location"))
|
||||||
|
case http.StatusInternalServerError:
|
||||||
|
body := map[string]string{}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &body))
|
||||||
|
assert.Equal(t, twirpInternal, body["code"])
|
||||||
|
assert.Contains(t, body["msg"], cmp.Or(tt.wantMsg, tt.cred.Results))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json/v2"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// S3Options configures the bucket that stores completed cache entries.
|
||||||
|
type S3Options struct {
|
||||||
|
Endpoint string
|
||||||
|
Region string
|
||||||
|
Bucket string
|
||||||
|
Prefix string
|
||||||
|
PathStyle *bool // nil uses virtual-hosted requests on AWS and path-style ones elsewhere
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheStorage interface {
|
||||||
|
Exist(uint64) (bool, error)
|
||||||
|
Write(uint64, int64, io.Reader) error
|
||||||
|
WriteBlock(uint64, string, io.Reader) error
|
||||||
|
OrderBlocks(uint64, []string) error
|
||||||
|
Commit(uint64, int64) (int64, error)
|
||||||
|
Serve(http.ResponseWriter, *http.Request, uint64)
|
||||||
|
Remove(uint64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3PartSize = 5 << 30 // the largest part S3 accepts
|
||||||
|
|
||||||
|
// s3Storage stages uploads on disk and keeps completed entries and their metadata in a bucket.
|
||||||
|
type s3Storage struct {
|
||||||
|
*Storage
|
||||||
|
client *http.Client
|
||||||
|
bucketURL *url.URL
|
||||||
|
opts S3Options
|
||||||
|
}
|
||||||
|
|
||||||
|
type s3Part struct {
|
||||||
|
PartNumber int
|
||||||
|
ETag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newS3Storage(staging *Storage, opts S3Options) (*s3Storage, error) {
|
||||||
|
endpoint, err := url.Parse(opts.Endpoint)
|
||||||
|
if err != nil || endpoint.Host == "" || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
|
||||||
|
return nil, fmt.Errorf("invalid S3 endpoint %q", opts.Endpoint)
|
||||||
|
}
|
||||||
|
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||||
|
transport = transport.Clone()
|
||||||
|
transport.ResponseHeaderTimeout = time.Minute // a stalled endpoint must not hold the store forever
|
||||||
|
pathStyle := !strings.HasSuffix(endpoint.Hostname(), ".amazonaws.com")
|
||||||
|
if opts.PathStyle != nil {
|
||||||
|
pathStyle = *opts.PathStyle
|
||||||
|
}
|
||||||
|
if pathStyle {
|
||||||
|
endpoint.Path = "/" + opts.Bucket
|
||||||
|
} else {
|
||||||
|
endpoint.Host = opts.Bucket + "." + endpoint.Host
|
||||||
|
endpoint.Path = "/"
|
||||||
|
}
|
||||||
|
opts.Region = cmp.Or(opts.Region, "us-east-1")
|
||||||
|
return &s3Storage{Storage: staging, client: &http.Client{Transport: transport}, bucketURL: endpoint, opts: opts}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) blobKey(id uint64) string {
|
||||||
|
return path.Join(s.opts.Prefix, "blobs", strconv.FormatUint(id, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) metadataDir() string {
|
||||||
|
return path.Join(s.opts.Prefix, "metadata") + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) metadataKey(id uint64) string {
|
||||||
|
return s.metadataDir() + strconv.FormatUint(id, 10) + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) url(key string, query url.Values) *url.URL {
|
||||||
|
u := *s.bucketURL
|
||||||
|
u.Path = path.Join(u.Path, key)
|
||||||
|
u.RawPath = strings.NewReplacer("+", "%20", "%2F", "/").Replace(url.QueryEscape(u.Path)) // SigV4 encoding
|
||||||
|
u.RawQuery = strings.ReplaceAll(query.Encode(), "+", "%20")
|
||||||
|
return &u
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign authorizes req with AWS Signature Version 4, leaving the payload unsigned so bodies can stream.
|
||||||
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
|
||||||
|
func (s *s3Storage) sign(req *http.Request, now time.Time) {
|
||||||
|
date := now.UTC().Format("20060102T150405Z")
|
||||||
|
req.Header.Set("X-Amz-Date", date)
|
||||||
|
if req.Header.Get("X-Amz-Content-Sha256") == "" {
|
||||||
|
req.Header.Set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD")
|
||||||
|
}
|
||||||
|
headers := map[string]string{"host": req.URL.Host}
|
||||||
|
for name, values := range req.Header {
|
||||||
|
if name = strings.ToLower(name); strings.HasPrefix(name, "x-amz-") {
|
||||||
|
headers[name] = strings.Join(values, ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := slices.Sorted(maps.Keys(headers))
|
||||||
|
canonical := []string{req.Method, req.URL.EscapedPath(), req.URL.RawQuery}
|
||||||
|
for _, name := range names {
|
||||||
|
canonical = append(canonical, name+":"+headers[name])
|
||||||
|
}
|
||||||
|
canonical = append(canonical, "", strings.Join(names, ";"), req.Header.Get("X-Amz-Content-Sha256"))
|
||||||
|
scope := date[:8] + "/" + s.opts.Region + "/s3/aws4_request"
|
||||||
|
signature := []byte("AWS4" + s.opts.SecretKey)
|
||||||
|
for _, data := range []string{date[:8], s.opts.Region, "s3", "aws4_request", fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%x", date, scope, sha256.Sum256([]byte(strings.Join(canonical, "\n"))))} { // key derivation, the last round signs
|
||||||
|
mac := hmac.New(sha256.New, signature)
|
||||||
|
_, _ = mac.Write([]byte(data))
|
||||||
|
signature = mac.Sum(nil)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s,SignedHeaders=%s,Signature=%x", s.opts.AccessKey, scope, strings.Join(names, ";"), signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// do returns the response to a signed request when it succeeded, fs.ErrNotExist for a missing key.
|
||||||
|
func (s *s3Storage) do(ctx context.Context, method, key string, query url.Values, header http.Header, body io.Reader) (*http.Response, error) {
|
||||||
|
var size int64
|
||||||
|
if sized, ok := body.(interface{ Size() int64 }); ok {
|
||||||
|
size = sized.Size()
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
body = nil
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, s.url(key, query).String(), body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.ContentLength = size
|
||||||
|
maps.Copy(req.Header, header)
|
||||||
|
s.sign(req, time.Now())
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusMultipleChoices {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
defer drain(resp)
|
||||||
|
var failure struct{ Code, Message string }
|
||||||
|
_ = xml.NewDecoder(resp.Body).Decode(&failure)
|
||||||
|
if resp.StatusCode == http.StatusNotFound && (failure.Code == "NoSuchKey" || failure.Code == "" && method == http.MethodHead) {
|
||||||
|
return nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("S3 %s %s: %s", method, key, cmp.Or(strings.TrimSpace(failure.Code+" "+failure.Message), resp.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) call(ctx context.Context, method, key string, query url.Values, body io.Reader, result any) (http.Header, error) {
|
||||||
|
resp, err := s.do(ctx, method, key, query, nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer drain(resp)
|
||||||
|
if result == nil {
|
||||||
|
return resp.Header, nil
|
||||||
|
}
|
||||||
|
return resp.Header, xml.NewDecoder(resp.Body).Decode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain reads a response to its end before closing it, so its connection gets reused.
|
||||||
|
func drain(resp *http.Response) {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) Exist(id uint64) (bool, error) {
|
||||||
|
_, err := s.call(context.Background(), http.MethodHead, s.blobKey(id), nil, nil, nil)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
|
written, err := s.Storage.Commit(id, size)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = s.Storage.Remove(id) }()
|
||||||
|
file, err := os.Open(s.filename(id))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if err := s.upload(context.Background(), s.blobKey(id), file, written); err != nil {
|
||||||
|
return 0, fmt.Errorf("upload cache to S3: %w", err)
|
||||||
|
}
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload always goes in parts, the only way S3 takes an object over 5 GiB.
|
||||||
|
func (s *s3Storage) upload(ctx context.Context, key string, file io.ReaderAt, size int64) error {
|
||||||
|
var created struct {
|
||||||
|
UploadID string `xml:"UploadId"`
|
||||||
|
}
|
||||||
|
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploads": {""}}, nil, &created); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var completion struct {
|
||||||
|
XMLName xml.Name `xml:"CompleteMultipartUpload"`
|
||||||
|
Parts []s3Part `xml:"Part"`
|
||||||
|
}
|
||||||
|
for offset := int64(0); offset == 0 || offset < size; offset += s3PartSize {
|
||||||
|
number, length := len(completion.Parts)+1, min(s3PartSize, size-offset)
|
||||||
|
query := url.Values{"partNumber": {strconv.Itoa(number)}, "uploadId": {created.UploadID}}
|
||||||
|
header, err := s.call(ctx, http.MethodPut, key, query, io.NewSectionReader(file, offset, length), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
completion.Parts = append(completion.Parts, s3Part{number, header.Get("ETag")})
|
||||||
|
}
|
||||||
|
body, err := xml.Marshal(completion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var result struct{ Code, Message string } // a completion can fail after its 200 status
|
||||||
|
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploadId": {created.UploadID}}, bytes.NewReader(body), &result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.Code != "" {
|
||||||
|
return fmt.Errorf("S3 complete upload %s: %s %s", key, result.Code, result.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||||
|
header := http.Header{}
|
||||||
|
if byteRange := r.Header.Get("Range"); byteRange != "" {
|
||||||
|
header.Set("Range", byteRange)
|
||||||
|
}
|
||||||
|
resp, err := s.do(r.Context(), http.MethodGet, s.blobKey(id), nil, header, nil)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
http.Error(w, "read cache from S3", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
for _, name := range []string{"Content-Length", "Content-Range"} {
|
||||||
|
if value := resp.Header.Get(name); value != "" {
|
||||||
|
w.Header().Set(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove unpublishes an entry before deleting its blob, so no runner imports it without its bytes.
|
||||||
|
func (s *s3Storage) Remove(id uint64) error {
|
||||||
|
for _, key := range []string{s.metadataKey(id), s.blobKey(id)} {
|
||||||
|
if _, err := s.call(context.Background(), http.MethodDelete, key, nil, nil, nil); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("remove %s from S3: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.Storage.Remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) publish(cache *Cache) error {
|
||||||
|
data, err := json.Marshal(cache)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.call(context.Background(), http.MethodPut, s.metadataKey(cache.ID), nil, bytes.NewReader(data), nil); err != nil {
|
||||||
|
return fmt.Errorf("publish cache to S3: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) published(ctx context.Context) (map[uint64]bool, error) {
|
||||||
|
ids := make(map[uint64]bool)
|
||||||
|
query := url.Values{"list-type": {"2"}, "prefix": {s.metadataDir()}}
|
||||||
|
for {
|
||||||
|
var page struct {
|
||||||
|
Contents []struct{ Key string }
|
||||||
|
IsTruncated bool
|
||||||
|
NextContinuationToken string
|
||||||
|
}
|
||||||
|
if _, err := s.call(ctx, http.MethodGet, "", query, nil, &page); err != nil {
|
||||||
|
return nil, fmt.Errorf("list S3 cache metadata: %w", err)
|
||||||
|
}
|
||||||
|
for _, object := range page.Contents {
|
||||||
|
if id, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimPrefix(object.Key, s.metadataDir()), ".json"), 10, 64); err == nil {
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !page.IsTruncated {
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
query.Set("continuation-token", page.NextContinuationToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) fetch(ctx context.Context, id uint64) (*Cache, error) {
|
||||||
|
resp, err := s.do(ctx, http.MethodGet, s.metadataKey(id), nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
cache := &Cache{}
|
||||||
|
if err := json.UnmarshalRead(resp.Body, cache); err != nil {
|
||||||
|
return nil, fmt.Errorf("read S3 cache metadata %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/johannesboyne/gofakes3"
|
||||||
|
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandler_S3SharesEntriesAndDropsExpiredOnes(t *testing.T) {
|
||||||
|
backend := s3mem.New()
|
||||||
|
require.NoError(t, backend.CreateBucket("cache"))
|
||||||
|
server := httptest.NewServer(gofakes3.New(backend).Server())
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
opts := Options{S3: &S3Options{Endpoint: server.URL, Bucket: "cache", Prefix: "runners", AccessKey: "key", SecretKey: "secret"}}
|
||||||
|
saver, restorer := startTestHandler(t, opts), startTestHandler(t, opts)
|
||||||
|
entry := map[string]any{"key": "deps", "version": "v1"}
|
||||||
|
|
||||||
|
finalized, _ := saveV2(t, saver, "deps", "v1", []byte("archive"))
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
require.NoError(t, restorer.importMetadata(t.Context()))
|
||||||
|
found := v2Call(t, restorer, testClient, "GetCacheEntryDownloadURL", entry)
|
||||||
|
require.Equal(t, true, found["ok"])
|
||||||
|
assert.Equal(t, []byte("archive"), getURL(t, found["signed_download_url"].(string)))
|
||||||
|
|
||||||
|
require.NoError(t, backend.ForceDeleteBucket("cache"))
|
||||||
|
require.NoError(t, backend.CreateBucket("cache"))
|
||||||
|
require.NoError(t, restorer.importMetadata(t.Context()))
|
||||||
|
assert.Equal(t, true, v2Call(t, restorer, testClient, "CreateCacheEntry", entry)["ok"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3StorageSignsLikeAWS(t *testing.T) {
|
||||||
|
storage := &s3Storage{opts: S3Options{Region: "us-east-1", AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", nil)
|
||||||
|
req.Header.Set("X-Amz-Content-Sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||||
|
storage.sign(req, time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC))
|
||||||
|
assert.Equal(t, "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date,Signature=34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7", req.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -66,6 +68,18 @@ func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error
|
|||||||
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||||
// never does, so no rename can collide with a block not yet moved.
|
// never does, so no rename can collide with a block not yet moved.
|
||||||
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||||
|
wanted, err := json.Marshal(blockIDs) // a block id is client-chosen, so it cannot be a separator
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list := filepath.Join(s.tempDir(id), blockFilePrefix+"list")
|
||||||
|
if recorded, err := os.ReadFile(list); err == nil {
|
||||||
|
// A retry repeats the list it already sent; another one would reorder what is now staged.
|
||||||
|
if !bytes.Equal(recorded, wanted) {
|
||||||
|
return fmt.Errorf("cache %d was already assembled from a different block list", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
for i, blockID := range blockIDs {
|
for i, blockID := range blockIDs {
|
||||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return os.WriteFile(list, wanted, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
|
|||||||
+39
-110
@@ -6,11 +6,12 @@ package artifacts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -50,65 +51,29 @@ 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 uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
func writeJSON(w http.ResponseWriter, value any) {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploads(router *httprouter.Router, baseDir string) {
|
||||||
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
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")
|
||||||
|
|
||||||
json, err := json.Marshal(FileContainerResourceURL{
|
writeJSON(w, 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) {
|
||||||
@@ -122,67 +87,47 @@ func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
|||||||
safeRunPath := safeResolve(baseDir, runID)
|
safeRunPath := safeResolve(baseDir, runID)
|
||||||
safePath := safeResolve(safeRunPath, itemPath)
|
safePath := safeResolve(safeRunPath, itemPath)
|
||||||
|
|
||||||
file, err := func() (WritableFile, error) {
|
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
|
||||||
contentRange := req.Header.Get("Content-Range")
|
panic(err)
|
||||||
if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
|
}
|
||||||
return fsys.OpenAppendable(safePath)
|
flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
|
||||||
}
|
appendUpload := req.Header.Get("Content-Range")
|
||||||
return fsys.OpenWritable(safePath)
|
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()
|
||||||
|
|
||||||
writer, ok := file.(io.Writer)
|
|
||||||
if !ok {
|
|
||||||
panic(errors.New("File is not writable"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Body == nil {
|
if req.Body == nil {
|
||||||
panic(errors.New("No body given"))
|
panic(errors.New("no body given"))
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(writer, req.Body)
|
_, err = io.Copy(file, req.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
json, err := json.Marshal(ResponseMessage{
|
writeJSON(w, 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) {
|
||||||
json, err := json.Marshal(ResponseMessage{
|
writeJSON(w, 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, fsys fs.FS) {
|
func downloads(router *httprouter.Router, baseDir string) {
|
||||||
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
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 := fs.ReadDir(fsys, safePath)
|
entries, err := os.ReadDir(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -195,18 +140,10 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
json, err := json.Marshal(NamedFileContainerResourceURLResponse{
|
writeJSON(w, NamedFileContainerResourceURLResponse{
|
||||||
Count: len(list),
|
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) {
|
||||||
@@ -215,7 +152,7 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
||||||
|
|
||||||
var files []ContainerItem
|
var files []ContainerItem
|
||||||
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error {
|
err := filepath.WalkDir(safePath, func(path string, entry fs.DirEntry, err error) error {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
rel, err := filepath.Rel(safePath, path)
|
rel, err := filepath.Rel(safePath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,17 +178,9 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
json, err := json.Marshal(ContainerItemResponse{
|
writeJSON(w, 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) {
|
||||||
@@ -259,15 +188,16 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
|
|
||||||
safePath := safeResolve(baseDir, path)
|
safePath := safeResolve(baseDir, path)
|
||||||
|
|
||||||
file, err := fsys.Open(safePath)
|
file, err := os.Open(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// try gzip file
|
// try gzip file
|
||||||
file, err = fsys.Open(safePath + gzipExtension)
|
file, err = os.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 {
|
||||||
@@ -287,19 +217,18 @@ 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)
|
||||||
fsys := readWriteFSImpl{}
|
uploads(router, artifactPath)
|
||||||
uploads(router, artifactPath, fsys)
|
downloads(router, artifactPath)
|
||||||
downloads(router, artifactPath, fsys)
|
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%s", addr, port),
|
Addr: net.JoinHostPort(addr, port),
|
||||||
ReadHeaderTimeout: 2 * time.Second,
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
}
|
}
|
||||||
|
|
||||||
// run server
|
// run server
|
||||||
go func() {
|
go func() {
|
||||||
logger.Infof("Start server on http://%s:%s", addr, port)
|
logger.Infof("Start server on http://%s", server.Addr)
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Fatal(err)
|
logger.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-314
@@ -7,8 +7,7 @@ package artifacts
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,238 +17,18 @@ 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()
|
||||||
fsys := readWriteFSImpl{}
|
uploads(router, artifactPath)
|
||||||
uploads(router, artifactPath, fsys)
|
downloads(router, artifactPath)
|
||||||
downloads(router, artifactPath, fsys)
|
|
||||||
server := httptest.NewServer(router)
|
server := httptest.NewServer(router)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -257,8 +36,6 @@ 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)
|
||||||
@@ -289,6 +66,8 @@ 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))
|
||||||
@@ -314,6 +93,21 @@ 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"
|
||||||
|
|
||||||
@@ -365,9 +159,7 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMkdirFsImplSafeResolve(t *testing.T) {
|
func TestSafeResolve(t *testing.T) {
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
baseDir := "/foo/bar"
|
baseDir := "/foo/bar"
|
||||||
|
|
||||||
tests := map[string]struct {
|
tests := map[string]struct {
|
||||||
@@ -385,97 +177,13 @@ func TestMkdirFsImplSafeResolve(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) {
|
||||||
assert.Equal(tc.want, safeResolve(baseDir, tc.input))
|
require.Equal(t, tc.want, safeResolve(baseDir, tc.input))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
|
|
||||||
fsys := readWriteFSImpl{}
|
|
||||||
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
|
|
||||||
|
|
||||||
w, err := fsys.OpenWritable(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = w.Write([]byte("first"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
|
|
||||||
w, err = fsys.OpenAppendable(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = w.Write([]byte("-second"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
|
|
||||||
got, err := os.ReadFile(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "first-second", string(got))
|
|
||||||
|
|
||||||
w, err = fsys.OpenWritable(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = w.Write([]byte("replaced"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
|
|
||||||
got, err = os.ReadFile(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "replaced", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
|
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))
|
|
||||||
}
|
|
||||||
+1
-24
@@ -54,22 +54,6 @@ 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 {
|
||||||
@@ -187,15 +171,8 @@ 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,43 +45,6 @@ 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.
|
||||||
@@ -223,10 +186,3 @@ 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+120
-133
@@ -36,7 +36,6 @@ 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.
|
||||||
@@ -187,19 +186,16 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindGithubRepo get the repo
|
// FindGithubRepo get the repo
|
||||||
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, error) {
|
||||||
goGitMu.Lock()
|
goGitMu.Lock()
|
||||||
defer goGitMu.Unlock()
|
defer goGitMu.Unlock()
|
||||||
if remoteName == "" {
|
|
||||||
remoteName = "origin"
|
|
||||||
}
|
|
||||||
|
|
||||||
url, err := findGitRemoteURL(ctx, file, remoteName)
|
url, err := findGitRemoteURL(ctx, file, "origin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_, slug, err := findGitSlug(url, githubInstance)
|
_, slug := findGitSlug(url, githubInstance)
|
||||||
return slug, err
|
return slug, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
||||||
@@ -226,25 +222,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, error) { //nolint:unparam // pre-existing issue from nektos/act
|
func findGitSlug(url, githubInstance string) (string, string) {
|
||||||
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "CodeCommit", matches[2], nil
|
return "CodeCommit", matches[2]
|
||||||
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "CodeCommit", matches[2], nil
|
return "CodeCommit", matches[2]
|
||||||
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
||||||
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
||||||
} else if githubInstance != "github.com" {
|
} 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]), nil
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
||||||
} 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]), nil
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", url, nil
|
return "", url
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
||||||
@@ -278,11 +274,12 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
|||||||
return r, true, nil
|
return r, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
switch {
|
||||||
|
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)
|
||||||
} else if len(remote.Config().URLs) == 0 {
|
case 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)
|
||||||
} else {
|
default:
|
||||||
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 {
|
||||||
@@ -328,33 +325,6 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
|||||||
return r, false, nil
|
return r, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
|
||||||
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
|
|
||||||
fetchOptions.Force = true
|
|
||||||
pullOptions.Force = true
|
|
||||||
|
|
||||||
if token != "" {
|
|
||||||
auth := &http.BasicAuth{
|
|
||||||
Username: "token",
|
|
||||||
Password: token,
|
|
||||||
}
|
|
||||||
fetchOptions.Auth = auth
|
|
||||||
pullOptions.Auth = auth
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchOptions, pullOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -376,25 +346,34 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
|
|
||||||
isOfflineMode := input.OfflineMode
|
isOfflineMode := input.OfflineMode
|
||||||
|
|
||||||
// fetch latest changes
|
fetchOptions := git.FetchOptions{
|
||||||
fetchOptions, pullOptions := gitOptions(input.Token)
|
RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD", "refs/heads/*:refs/remotes/origin/*"},
|
||||||
|
Force: true,
|
||||||
if input.InsecureSkipTLS { // For Gitea
|
InsecureSkipTLS: input.InsecureSkipTLS,
|
||||||
fetchOptions.InsecureSkipTLS = true
|
}
|
||||||
pullOptions.InsecureSkipTLS = true
|
if input.Token != "" {
|
||||||
|
fetchOptions.Auth = &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: input.Token,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||||
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||||
shallow := isShallow(r)
|
if isShallow(r) {
|
||||||
if shallow {
|
|
||||||
fetchOptions.Depth = 1
|
fetchOptions.Depth = 1
|
||||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isOfflineMode {
|
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
||||||
|
// TODO: revalidate a mutable ref with a conditional archive request instead, once every
|
||||||
|
// supported Gitea sends an ETag for them: https://github.com/go-gitea/gitea/pull/39289
|
||||||
|
_, present := refRevision(r, input.Ref)
|
||||||
|
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
|
||||||
|
|
||||||
|
if refresh {
|
||||||
err = r.FetchContext(ctx, &fetchOptions)
|
err = r.FetchContext(ctx, &fetchOptions)
|
||||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
return err
|
return err
|
||||||
@@ -414,23 +393,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point we need to know if it's a tag or a branch
|
rev, _ = refRevision(r, input.Ref)
|
||||||
// And the easiest way to do it is duck typing
|
|
||||||
//
|
|
||||||
// If err is nil, it's a tag so let's proceed with that hash like we would if
|
|
||||||
// it was a sha
|
|
||||||
refType := "tag"
|
|
||||||
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
|
|
||||||
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
|
|
||||||
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
|
||||||
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
|
|
||||||
refType = "sha"
|
|
||||||
rev = plumbing.Revision(input.Ref)
|
|
||||||
} else {
|
|
||||||
refType = "branch"
|
|
||||||
rev = plumbing.Revision(rName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
@@ -442,47 +405,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the hash resolved doesn't match the ref provided in a workflow then we're
|
|
||||||
// using a branch or tag ref, not a sha
|
|
||||||
//
|
|
||||||
// Repos on disk point to commit hashes, and need to checkout input.Ref before
|
|
||||||
// we try and pull down any changes
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
|
||||||
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
|
|
||||||
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
|
||||||
if err = w.Checkout(&git.CheckoutOptions{
|
|
||||||
Branch: sourceRef,
|
|
||||||
Force: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reusedMsg := ""
|
reusedMsg := ""
|
||||||
|
if isOfflineMode && reused {
|
||||||
switch {
|
|
||||||
case !isOfflineMode && !shallow:
|
|
||||||
// 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) {
|
|
||||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
|
||||||
}
|
|
||||||
if err := staleRefreshErr(ctx, err); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case isOfflineMode && reused:
|
|
||||||
reusedMsg = " (reused in offline mode)"
|
reusedMsg = " (reused in offline mode)"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
||||||
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
|
||||||
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err = w.Checkout(&git.CheckoutOptions{
|
if err = w.Checkout(&git.CheckoutOptions{
|
||||||
Hash: *hash,
|
Hash: *hash,
|
||||||
Force: true,
|
Force: true,
|
||||||
@@ -491,40 +420,43 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = w.Reset(&git.ResetOptions{
|
|
||||||
Mode: git.HardReset,
|
|
||||||
Commit: *hash,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debugf("Checked out %s", input.Ref)
|
logger.Debugf("Checked out %s", input.Ref)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloneAtDepth clones input.URL into input.Dir using opts.
|
// cloneAtDepth falls back to a full clone when the shallow attempt fails.
|
||||||
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
|
||||||
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||||
if input.Depth > 0 {
|
if input.Depth > 0 {
|
||||||
for _, refName := range []plumbing.ReferenceName{
|
if plumbing.IsHash(input.Ref) {
|
||||||
plumbing.NewBranchReferenceName(input.Ref),
|
r, err := fetchPinnedSHA(ctx, input, opts)
|
||||||
plumbing.NewTagReferenceName(input.Ref),
|
|
||||||
} {
|
|
||||||
shallowOpts := opts
|
|
||||||
shallowOpts.Depth = input.Depth
|
|
||||||
shallowOpts.SingleBranch = true
|
|
||||||
shallowOpts.ReferenceName = refName
|
|
||||||
shallowOpts.Tags = git.NoTags
|
|
||||||
|
|
||||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
logger.Debugf("Shallow fetch of %s at %s failed: %v", input.URL, input.Ref, err)
|
||||||
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
if err := removePartialClone(input.Dir); err != nil {
|
||||||
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
refNames := []plumbing.ReferenceName{plumbing.NewBranchReferenceName(input.Ref), plumbing.NewTagReferenceName(input.Ref)}
|
||||||
|
if input.Ref == plumbing.HEAD.String() || strings.HasPrefix(input.Ref, "refs/") {
|
||||||
|
refNames = []plumbing.ReferenceName{plumbing.ReferenceName(input.Ref)} // HEAD is the remote's default branch
|
||||||
|
}
|
||||||
|
for _, refName := range refNames {
|
||||||
|
shallowOpts := opts
|
||||||
|
shallowOpts.Depth = input.Depth
|
||||||
|
shallowOpts.SingleBranch = true
|
||||||
|
shallowOpts.ReferenceName = refName
|
||||||
|
shallowOpts.Tags = git.NoTags
|
||||||
|
|
||||||
|
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||||
|
if err == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||||
|
if err := removePartialClone(input.Dir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||||
@@ -533,14 +465,65 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
|
|||||||
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removePartialClone(dir string) error {
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return fmt.Errorf("remove partial clone %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPinnedSHA(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions) (*git.Repository, error) {
|
||||||
|
r, err := git.PlainInit(input.Dir, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := r.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{input.URL}}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.FetchContext(ctx, &git.FetchOptions{
|
||||||
|
RefSpecs: []config.RefSpec{pinnedRefSpec(input.Ref)},
|
||||||
|
Depth: input.Depth,
|
||||||
|
Tags: git.NoTags,
|
||||||
|
Auth: opts.Auth,
|
||||||
|
Progress: opts.Progress,
|
||||||
|
InsecureSkipTLS: opts.InsecureSkipTLS,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinnedRefSpec keeps a hash-shaped name out of refs/heads and refs/tags.
|
||||||
|
func pinnedRefSpec(sha string) config.RefSpec {
|
||||||
|
return config.RefSpec(fmt.Sprintf("+%s:refs/pinned/%s", sha, sha))
|
||||||
|
}
|
||||||
|
|
||||||
|
// refRevision picks the revision to check out and reports whether it resolves locally.
|
||||||
|
func refRevision(r *git.Repository, ref string) (plumbing.Revision, bool) {
|
||||||
|
if plumbing.IsHash(ref) {
|
||||||
|
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
|
||||||
|
_, err := r.CommitObject(plumbing.NewHash(ref))
|
||||||
|
return plumbing.Revision(ref), err == nil
|
||||||
|
}
|
||||||
|
if _, err := r.Tag(ref); err == nil {
|
||||||
|
return plumbing.Revision(path.Join("refs", "tags", ref)), true
|
||||||
|
}
|
||||||
|
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
|
||||||
|
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||||
|
return plumbing.Revision(remoteRef), true
|
||||||
|
}
|
||||||
|
rev := plumbing.Revision(ref)
|
||||||
|
_, err := r.ResolveRevision(rev)
|
||||||
|
return rev, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||||
func isShallow(r *git.Repository) bool {
|
func isShallow(r *git.Repository) bool {
|
||||||
shallows, err := r.Storer.Shallow()
|
shallows, err := r.Storer.Shallow()
|
||||||
return err == nil && len(shallows) > 0
|
return err == nil && len(shallows) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
// shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
|
||||||
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
|
||||||
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||||
tagRef := plumbing.NewTagReferenceName(ref)
|
tagRef := plumbing.NewTagReferenceName(ref)
|
||||||
if _, err := r.Reference(tagRef, false); err == nil {
|
if _, err := r.Reference(tagRef, false); err == nil {
|
||||||
@@ -551,5 +534,9 @@ func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
|||||||
branchRef := plumbing.NewBranchReferenceName(ref)
|
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||||
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||||
}
|
}
|
||||||
|
if plumbing.IsHash(ref) {
|
||||||
|
// The broad refspec carries only advertised tips, never a pinned commit.
|
||||||
|
return pinnedRefSpec(ref), true
|
||||||
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
+140
-52
@@ -6,7 +6,6 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -16,6 +15,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,6 +24,9 @@ import (
|
|||||||
|
|
||||||
gogit "github.com/go-git/go-git/v5"
|
gogit "github.com/go-git/go-git/v5"
|
||||||
gogitconfig "github.com/go-git/go-git/v5/config"
|
gogitconfig "github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
|
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||||
|
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -51,9 +54,7 @@ func TestFindGitSlug(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range slugTests {
|
for _, tt := range slugTests {
|
||||||
provider, slug, err := findGitSlug(tt.url, "github.com")
|
provider, slug := 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)
|
||||||
}
|
}
|
||||||
@@ -87,45 +88,20 @@ func cleanGitHooks(dir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindGitRemoteURL(t *testing.T) {
|
func TestFindGithubRepoUsesOrigin(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 = "[email protected]/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", "https://github.com/owner/repo.git"))
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "ghe", "[email protected]:team/project.git"))
|
|
||||||
|
|
||||||
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
|
url, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
||||||
|
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) {
|
||||||
@@ -462,7 +438,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
||||||
}
|
}
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||||
|
|
||||||
@@ -488,14 +464,43 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
|
||||||
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||||
|
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("commit hash falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||||
})(t.Context()))
|
})(t.Context()))
|
||||||
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
assert.NoFileExists(t, shallowMarker(dir))
|
||||||
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
})
|
||||||
|
|
||||||
|
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
|
||||||
|
remote := unadvertisedRemote(t, true)
|
||||||
|
dir := t.TempDir()
|
||||||
|
cloneAt := func(ref string) error {
|
||||||
|
return NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remote, Ref: ref, Dir: dir, Depth: 1,
|
||||||
|
})(t.Context())
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, cloneAt(unadvertisedSHA))
|
||||||
|
assert.FileExists(t, shallowMarker(dir))
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
olderSHA := gitRevParse(t, workDir, "HEAD~2")
|
||||||
|
require.NoError(t, cloneAt(olderSHA))
|
||||||
|
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
require.NoError(t, os.RemoveAll(remote))
|
||||||
|
require.NoError(t, cloneAt(olderSHA))
|
||||||
|
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||||
@@ -518,6 +523,98 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorTransportSessions(t *testing.T) {
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--initial-branch=main", workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "-a", "v2", "-m", "v2"))
|
||||||
|
|
||||||
|
for name, tt := range map[string]struct {
|
||||||
|
Ref string
|
||||||
|
Depth int
|
||||||
|
}{
|
||||||
|
"shallow branch": {"main", 1},
|
||||||
|
"full clone branch": {"main", 0},
|
||||||
|
"full lightweight tag": {"v1", 0},
|
||||||
|
"full annotated tag": {"v2", 0},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
counter := installCountingTransport(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: workDir, Ref: tt.Ref, Dir: dir, Depth: tt.Depth,
|
||||||
|
})
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Swap(0), "cold clone")
|
||||||
|
assert.Equal(t, gitRevParse(t, workDir, tt.Ref+"^{commit}"), gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Swap(0), "unchanged warm cache")
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.yml"), []byte(name), 0o644))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "add", "action.yml"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", name))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "v1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "-a", "v2", "-m", "v2"))
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("staged"), 0o644))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "add", "action.yml"))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("unstaged"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Load(), "updated warm cache")
|
||||||
|
assert.Equal(t, gitRevParse(t, workDir, "HEAD"), gitRevParse(t, dir, "HEAD"))
|
||||||
|
status, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, string(status))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "pinned"))
|
||||||
|
pinned := gitRevParse(t, workDir, "HEAD")
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
|
||||||
|
|
||||||
|
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.Equal(t, pinned, gitRevParse(t, dir, "HEAD"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingTransport struct {
|
||||||
|
transport.Transport
|
||||||
|
sessions atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
|
||||||
|
c.sessions.Add(1)
|
||||||
|
return c.Transport.NewUploadPackSession(ep, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installCountingTransport(t *testing.T) *countingTransport {
|
||||||
|
t.Helper()
|
||||||
|
counter := &countingTransport{Transport: gogitfile.DefaultClient}
|
||||||
|
gogitclient.InstallProtocol("file", counter)
|
||||||
|
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
|
||||||
|
|
||||||
|
return counter
|
||||||
|
}
|
||||||
|
|
||||||
func gitRevParse(t *testing.T, dir, rev string) string {
|
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||||
@@ -659,12 +756,3 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
|||||||
t.Fatal("fetch ignored context cancellation")
|
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 {
|
||||||
lw.handleLine(lw.buffer.String())
|
if err == io.EOF {
|
||||||
lw.buffer.Reset()
|
break
|
||||||
} else if err == io.EOF {
|
}
|
||||||
break
|
|
||||||
} else {
|
|
||||||
return written, err
|
return written, err
|
||||||
}
|
}
|
||||||
|
lw.handleLine(lw.buffer.String())
|
||||||
|
lw.buffer.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
return written, nil
|
return written, nil
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -25,26 +29,27 @@ func (e ExitCodeError) Error() string {
|
|||||||
|
|
||||||
// NewContainerInput the input for the New function
|
// NewContainerInput the input for the New function
|
||||||
type NewContainerInput struct {
|
type NewContainerInput struct {
|
||||||
Image string
|
Image string
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
Entrypoint []string
|
Entrypoint []string
|
||||||
Cmd []string
|
Cmd []string
|
||||||
WorkingDir string
|
WorkingDir string
|
||||||
Env []string
|
Env []string
|
||||||
Binds []string
|
Binds []string
|
||||||
Mounts map[string]string
|
Mounts map[string]string
|
||||||
Name string
|
Name string
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
Stderr io.Writer
|
Stderr io.Writer
|
||||||
NetworkMode string
|
NetworkMode string
|
||||||
Privileged bool
|
Privileged bool
|
||||||
UsernsMode string
|
UsernsMode string
|
||||||
Platform string
|
Platform string
|
||||||
Options string
|
RunnerOptions string // container options the runner was configured with, trusted
|
||||||
NetworkAliases []string
|
WorkflowOptions string // container options the workflow asked for, untrusted
|
||||||
ExposedPorts nat.PortSet
|
NetworkAliases []string
|
||||||
PortBindings nat.PortMap
|
ExposedPorts nat.PortSet
|
||||||
|
PortBindings nat.PortMap
|
||||||
|
|
||||||
// Gitea specific
|
// Gitea specific
|
||||||
AutoRemove bool
|
AutoRemove bool
|
||||||
@@ -74,6 +79,24 @@ const (
|
|||||||
// fragment, missingContainerError composes it into the message every operation shares.
|
// fragment, missingContainerError composes it into the message every operation shares.
|
||||||
var ErrContainerNotFound = errors.New("does not exist")
|
var ErrContainerNotFound = errors.New("does not exist")
|
||||||
|
|
||||||
|
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
|
||||||
|
type DockerProxy struct {
|
||||||
|
Socket string
|
||||||
|
close func(context.Context) error
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
|
mounts atomic.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DockerProxy) SetMounts(mounts map[string]string) {
|
||||||
|
p.mounts.Store(mounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DockerProxy) Close(ctx context.Context) error {
|
||||||
|
p.closeOnce.Do(func() { p.closeErr = p.close(ctx) })
|
||||||
|
return p.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
// Info is a snapshot of a container, as of one inspect.
|
// Info is a snapshot of a container, as of one inspect.
|
||||||
type Info struct {
|
type Info struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -83,15 +106,24 @@ type Info struct {
|
|||||||
// HealthOutput is the last healthcheck probe's output.
|
// HealthOutput is the last healthcheck probe's output.
|
||||||
HealthOutput string
|
HealthOutput string
|
||||||
Ports map[string]string // container port ("5432") to the host port it is published on
|
Ports map[string]string // container port ("5432") to the host port it is published on
|
||||||
|
Mounts map[string]string // container path to its source on the daemon
|
||||||
|
}
|
||||||
|
|
||||||
|
// DaemonPath maps target to its daemon path through the nearest mount with a source, empty without one.
|
||||||
|
func (info *Info) DaemonPath(target string) string {
|
||||||
|
for dir := target; dir != "/" && dir != "."; dir = path.Dir(dir) {
|
||||||
|
if source := info.Mounts[dir]; source != "" {
|
||||||
|
return path.Join(source, strings.TrimPrefix(target, dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container for managing docker run containers
|
// 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, skipGitDir 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)
|
||||||
DumpLogs(ctx context.Context) error
|
DumpLogs(ctx context.Context) error
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts.go with:
|
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
||||||
|
// * jsontext in place of json.Compact for the seccomp profile
|
||||||
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
||||||
//
|
//
|
||||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||||
@@ -17,8 +18,7 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"encoding/json/jsontext"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -153,6 +153,7 @@ type containerOptions struct {
|
|||||||
runtime string
|
runtime string
|
||||||
autoRemove bool
|
autoRemove bool
|
||||||
init bool
|
init bool
|
||||||
|
umask opts.UmaskOpt
|
||||||
annotations *opts.MapOpts
|
annotations *opts.MapOpts
|
||||||
|
|
||||||
Image string
|
Image string
|
||||||
@@ -327,6 +328,9 @@ func addFlags(flags *pflag.FlagSet) *containerOptions {
|
|||||||
flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm")
|
flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm")
|
||||||
flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use")
|
flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use")
|
||||||
flags.StringVar(&copts.runtime, "runtime", "", "Runtime to use for this container")
|
flags.StringVar(&copts.runtime, "runtime", "", "Runtime to use for this container")
|
||||||
|
flags.Var(&copts.umask, "umask", "Set umask for the container")
|
||||||
|
flags.SetAnnotation("umask", "version", []string{"1.56"})
|
||||||
|
flags.SetAnnotation("umask", "ostype", []string{"linux"})
|
||||||
|
|
||||||
flags.BoolVar(&copts.init, "init", false, "Run an init inside the container that forwards signals and reaps processes")
|
flags.BoolVar(&copts.init, "init", false, "Run an init inside the container that forwards signals and reaps processes")
|
||||||
flags.SetAnnotation("init", "version", []string{"1.25"})
|
flags.SetAnnotation("init", "version", []string{"1.25"})
|
||||||
@@ -351,7 +355,7 @@ type containerConfig struct {
|
|||||||
// parse parses the args for the specified command and generates a Config,
|
// 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 // verbatim copy from docker/cli
|
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { //nolint:gocyclo,unparam // verbatim copy from docker/cli
|
||||||
var (
|
var (
|
||||||
attachStdin = copts.attach.Get("stdin")
|
attachStdin = copts.attach.Get("stdin")
|
||||||
attachStdout = copts.attach.Get("stdout")
|
attachStdout = copts.attach.Get("stdout")
|
||||||
@@ -722,6 +726,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
MaskedPaths: maskedPaths,
|
MaskedPaths: maskedPaths,
|
||||||
ReadonlyPaths: readonlyPaths,
|
ReadonlyPaths: readonlyPaths,
|
||||||
Annotations: copts.annotations.GetAll(),
|
Annotations: copts.annotations.GetAll(),
|
||||||
|
Umask: copts.umask.Value(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() {
|
if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() {
|
||||||
@@ -959,11 +964,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)
|
||||||
}
|
}
|
||||||
var b bytes.Buffer
|
profile := jsontext.Value(f)
|
||||||
if err := json.Compact(&b, f); err != nil {
|
if err := profile.Compact(); 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=" + b.String()
|
securityOpts[key] = "seccomp=" + string(profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts_test.go with:
|
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts_test.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter
|
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter
|
||||||
//
|
//
|
||||||
@@ -167,6 +167,19 @@ func TestParseRunLinks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRunWithoutUmask(t *testing.T) {
|
||||||
|
_, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"})
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Assert(t, hostConfig.Umask == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunUmask(t *testing.T) {
|
||||||
|
_, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"})
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Assert(t, hostConfig.Umask != nil)
|
||||||
|
assert.Equal(t, uint32(0o22), *hostConfig.Umask)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRunAttach(t *testing.T) {
|
func TestParseRunAttach(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -51,15 +53,16 @@ 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
|
||||||
@@ -73,6 +76,30 @@ 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", Options: "--platform linux/arm64 --pull never"}
|
input := &NewContainerInput{Platform: "linux/amd64", RunnerOptions: "--pull never", WorkflowOptions: "--platform linux/arm64"}
|
||||||
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", Options: "--privileged"}
|
kept := &NewContainerInput{Platform: "linux/amd64", RunnerOptions: "--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"
|
"encoding/json/v2"
|
||||||
"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
|
Message string `json:"message"`
|
||||||
}
|
} `json:"errorDetail"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Progress string `json:"progress"`
|
Progress string `json:"progress"`
|
||||||
}
|
}
|
||||||
@@ -60,15 +60,16 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
|||||||
return errors.New(msg.ErrorDetail.Message)
|
return errors.New(msg.ErrorDetail.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Status != "" {
|
switch {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
} else if msg.Stream != "" {
|
case msg.Stream != "":
|
||||||
writeLog(logger, isError, "%s", msg.Stream)
|
writeLog(logger, isError, "%s", msg.Stream)
|
||||||
} else {
|
default:
|
||||||
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,17 +35,17 @@ const (
|
|||||||
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
||||||
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
||||||
// while this runs cannot lose the network it has created but not yet attached a container to.
|
// while this runs cannot lose the network it has created but not yet attached a container to.
|
||||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
func RemoveOrphanNetworks(ctx context.Context, runnerUUID, detach string, createdBefore time.Time) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore)
|
return removeOrphanNetworks(ctx, cli, runnerUUID, detach, createdBefore)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error {
|
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID, detach string, createdBefore time.Time) error {
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
||||||
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||||
})
|
})
|
||||||
@@ -51,6 +54,7 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
|
|||||||
}
|
}
|
||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
|
networks:
|
||||||
for _, n := range networks.Items {
|
for _, n := range networks.Items {
|
||||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,9 +63,18 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
|
|||||||
}
|
}
|
||||||
// the emptiness check, not the label, is what keeps a live job of another process
|
// the emptiness check, not the label, is what keeps a live job of another process
|
||||||
// sharing this registration safe
|
// sharing this registration safe
|
||||||
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) {
|
if len(result.Network.Containers) > 1 || result.Network.Created.After(createdBefore) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
for containerID := range result.Network.Containers {
|
||||||
|
if detach == "" || !strings.HasPrefix(containerID, detach) {
|
||||||
|
continue networks
|
||||||
|
}
|
||||||
|
if err := disconnectNetwork(ctx, cli, n.ID, containerID); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to disconnect container from network %s: %w", n.Name, err))
|
||||||
|
continue networks
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||||
continue
|
continue
|
||||||
@@ -134,6 +147,50 @@ func isAddressPoolExhausted(err error) bool {
|
|||||||
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkConnectExecutor(networkName, container string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
if err := connectNetwork(ctx, cli, networkName, container); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("cannot connect %s to network %s, so jobs on it cannot reach the cache: %v", container, networkName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
|
||||||
|
_, err := cli.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
|
||||||
|
Container: container,
|
||||||
|
EndpointConfig: &network.EndpointSettings{Aliases: []string{container}},
|
||||||
|
})
|
||||||
|
if err != nil && (strings.Contains(err.Error(), "already exists in network") || strings.Contains(err.Error(), "already connected")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkDisconnectExecutor(networkName, container string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
return disconnectNetwork(ctx, cli, networkName, container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
|
||||||
|
_, err := cli.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{Container: container, Force: true})
|
||||||
|
if cerrdefs.IsNotFound(err) || (err != nil && strings.Contains(err.Error(), "is not connected")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
@@ -172,3 +229,49 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsolatedCacheContainer returns the container holding addr, when jobs on jobNetwork cannot reach it there.
|
||||||
|
func IsolatedCacheContainer(ctx context.Context, addr netip.Addr, jobNetwork string) (string, error) {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
return isolatedCacheContainer(ctx, cli, addr, jobNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isolatedCacheContainer(ctx context.Context, cli client.APIClient, addr netip.Addr, jobNetwork string) (string, error) {
|
||||||
|
if jobNetwork == "host" || strings.HasPrefix(jobNetwork, "container:") {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var holderID, holderContainer string
|
||||||
|
for _, summary := range containers.Items {
|
||||||
|
if summary.NetworkSettings == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, endpoint := range summary.NetworkSettings.Networks {
|
||||||
|
if endpoint != nil && (endpoint.IPAddress == addr || endpoint.GlobalIPv6Address == addr) {
|
||||||
|
holderID = endpoint.NetworkID
|
||||||
|
holderContainer = summary.ID[:12]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if holderID == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
holder, err := cli.NetworkInspect(ctx, holderID, client.NetworkInspectOptions{})
|
||||||
|
if err != nil || holder.Network.Driver != "bridge" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if jobNetwork != "" {
|
||||||
|
job, err := cli.NetworkInspect(ctx, jobNetwork, client.NetworkInspectOptions{})
|
||||||
|
if err != nil || job.Network.ID == holderID {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return holderContainer, nil
|
||||||
|
}
|
||||||
@@ -6,25 +6,95 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestIsolatedCacheContainer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
|
||||||
|
ID: "0123456789abcdef",
|
||||||
|
NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{
|
||||||
|
"compose": {NetworkID: "c0ffee", IPAddress: cache},
|
||||||
|
"lan": {NetworkID: "beef", IPAddress: lan},
|
||||||
|
}},
|
||||||
|
}}}, nil)
|
||||||
|
for ref, inspected := range map[string]network.Network{
|
||||||
|
"c0ffee": {ID: "c0ffee", Name: "compose", Driver: "bridge"},
|
||||||
|
"beef": {ID: "beef", Name: "lan", Driver: "macvlan"},
|
||||||
|
"c0f": {ID: "c0ffee"},
|
||||||
|
"jobs": {ID: "f00d"},
|
||||||
|
} {
|
||||||
|
client.On("NetworkInspect", ctx, ref, mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: inspected}}, nil)
|
||||||
|
}
|
||||||
|
check := func(addr netip.Addr, jobNetwork, want string) {
|
||||||
|
got, err := isolatedCacheContainer(ctx, client, addr, jobNetwork)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got, jobNetwork)
|
||||||
|
}
|
||||||
|
check(cache, "", "0123456789ab")
|
||||||
|
check(cache, "jobs", "0123456789ab")
|
||||||
|
check(cache, "c0f", "")
|
||||||
|
check(cache, "host", "")
|
||||||
|
check(lan, "", "")
|
||||||
|
check(netip.MustParseAddr("10.0.0.5"), "", "")
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsAddressPoolExhausted(t *testing.T) {
|
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||||
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||||
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||||
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
|
func (m *mockDockerClient) NetworkConnect(ctx context.Context, id string, opts mobyclient.NetworkConnectOptions) (mobyclient.NetworkConnectResult, error) {
|
||||||
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||||
// test on its own, since testify has no expectation to match it against.
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkDisconnect(ctx context.Context, id string, opts mobyclient.NetworkDisconnectOptions) (mobyclient.NetworkDisconnectResult, error) {
|
||||||
|
return mobyclient.NetworkDisconnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectAndDisconnectNetwork(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
connect := func(answer error) error {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
|
||||||
|
Container: "0123456789ab",
|
||||||
|
EndpointConfig: &network.EndpointSettings{Aliases: []string{"0123456789ab"}},
|
||||||
|
}).Return(answer).Once()
|
||||||
|
return connectNetwork(ctx, client, "jobs", "0123456789ab")
|
||||||
|
}
|
||||||
|
require.NoError(t, connect(nil))
|
||||||
|
require.NoError(t, connect(errors.New("endpoint with name runner already exists in network jobs")))
|
||||||
|
require.NoError(t, connect(errors.New("container runner already connected to network jobs")))
|
||||||
|
require.ErrorIs(t, connect(context.Canceled), context.Canceled)
|
||||||
|
|
||||||
|
disconnect := func(answer error) error {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkDisconnect", ctx, "jobs", mobyclient.NetworkDisconnectOptions{
|
||||||
|
Container: "0123456789ab", Force: true,
|
||||||
|
}).Return(answer).Once()
|
||||||
|
return disconnectNetwork(ctx, client, "jobs", "0123456789ab")
|
||||||
|
}
|
||||||
|
require.NoError(t, disconnect(cerrdefs.ErrNotFound))
|
||||||
|
require.NoError(t, disconnect(errors.New("container runner is not connected to the network jobs")))
|
||||||
|
require.ErrorIs(t, disconnect(context.Canceled), context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of this runner's networks, only the ones nothing but its own cache container is attached to
|
||||||
|
// and old enough to predate any job now starting are the runner's to reclaim. An unexpected
|
||||||
|
// NetworkRemove fails the test on its own, since testify has no expectation to match it against.
|
||||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
@@ -32,19 +102,32 @@ 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{
|
||||||
{Network: network.Network{ID: "orphan"}},
|
{ID: "orphan"},
|
||||||
{Network: network.Network{ID: "busy"}},
|
{ID: "busy"},
|
||||||
{Network: network.Network{ID: "starting"}},
|
{ID: "starting"},
|
||||||
|
{ID: "cached"},
|
||||||
|
{ID: "shared"},
|
||||||
}}, nil)
|
}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||||
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{
|
||||||
|
Network: network.Network{Created: cutoff.Add(time.Second)},
|
||||||
|
Containers: map[string]network.EndpointResource{"0123456789abcdef": {}},
|
||||||
|
}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "cached", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}}}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "shared", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}, "job": {}}}}, nil)
|
||||||
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||||
|
client.On("NetworkDisconnect", ctx, "cached", mobyclient.NetworkDisconnectOptions{Container: "0123456789abcdef", Force: true}).
|
||||||
|
Return(nil).Once()
|
||||||
|
client.On("NetworkRemove", ctx, "cached", mobyclient.NetworkRemoveOptions{}).
|
||||||
|
Return(mobyclient.NetworkRemoveResult{}, nil).Once()
|
||||||
|
|
||||||
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff))
|
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", "0123456789ab", cutoff))
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
|
"github.com/moby/moby/api/types/mount"
|
||||||
|
"github.com/moby/moby/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
jobLabel = "com.gitea.runner.job"
|
||||||
|
maxCreateBody = 8 << 20
|
||||||
|
|
||||||
|
dockerProxyProbeTimeout = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
|
||||||
|
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
|
||||||
|
if host := os.Getenv("DOCKER_HOST"); runtime.GOOS != "linux" || host != "" && !strings.HasPrefix(host, "unix://") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cli, err := GetDockerClient(probeCtx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
daemonSocket, ok := strings.CutPrefix(cli.DaemonHost(), "unix://")
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir, daemonDir := runnerContainerWorkdir(probeCtx, cli)
|
||||||
|
seen := daemonDir != ""
|
||||||
|
if !seen {
|
||||||
|
if dir, err = filepath.Abs(os.TempDir()); err == nil {
|
||||||
|
daemonDir = dir
|
||||||
|
seen, err = daemonSeesDir(probeCtx, cli, dir, daemonDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !seen {
|
||||||
|
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proxy, err := StartDockerProxy(daemonSocket, dir, job)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proxy.Socket = daemonDir + strings.TrimPrefix(proxy.Socket, dir)
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
// runnerContainerWorkdir looks the runner's container up by hostname to find the daemon's path to its working directory.
|
||||||
|
func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir, daemonDir string) {
|
||||||
|
workdir, err := os.Getwd()
|
||||||
|
hostname, hostnameErr := os.Hostname()
|
||||||
|
if err != nil || hostnameErr != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
self, err := cli.ContainerInspect(ctx, hostname, client.ContainerInspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if daemonDir = containerInfoFromInspect(self.Container).DaemonPath(workdir); daemonDir == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if seen, _ := containerSeesMarker(ctx, cli, workdir, self.Container.ID, workdir); !seen { // the hostname may name another container
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return workdir, daemonDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerSeesMarker(ctx context.Context, cli client.APIClient, dir, id, containerDir string) (bool, error) {
|
||||||
|
marker, err := os.CreateTemp(dir, "gitea-runner-probe-")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.Remove(marker.Name()); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("removing the docker proxy probe marker failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := marker.Close(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_, err = cli.ContainerStatPath(ctx, id, client.ContainerStatPathOptions{Path: path.Join(containerDir, filepath.Base(marker.Name()))})
|
||||||
|
if cerrdefs.IsNotFound(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir by their path in daemonDir,
|
||||||
|
// which is what a job's proxy socket mounted from there needs.
|
||||||
|
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir string) (bool, error) {
|
||||||
|
images, err := cli.ImageList(ctx, client.ImageListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(images.Items) == 0 {
|
||||||
|
return false, errors.New("no image available for the docker proxy probe")
|
||||||
|
}
|
||||||
|
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
||||||
|
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
|
||||||
|
HostConfig: &container.HostConfig{Mounts: []mount.Mount{
|
||||||
|
{Type: mount.TypeBind, Source: daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}, // not the marker itself, podman creates a missing bind source where docker rejects it
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if cerrdefs.IsInvalidArgument(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
seen, err := containerSeesMarker(ctx, cli, dir, created.ID, "/gitea-runner-probe")
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if _, removeErr := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
|
||||||
|
return false, fmt.Errorf("removing the docker proxy probe container failed: %w", removeErr)
|
||||||
|
}
|
||||||
|
return seen, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
|
||||||
|
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||||
|
info, err := os.Stat(daemonSocket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSocket == 0 {
|
||||||
|
return nil, errors.New("docker daemon path is not a Unix socket")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
instance, err := os.MkdirTemp(dir, "p-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
socket := filepath.Join(instance, "docker.sock")
|
||||||
|
listener, err := net.Listen("unix", socket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Join(err, os.RemoveAll(instance))
|
||||||
|
}
|
||||||
|
if err := copyDockerSocketPermissions(daemonSocket, socket, info); err != nil {
|
||||||
|
return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance))
|
||||||
|
}
|
||||||
|
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
|
||||||
|
}
|
||||||
|
transport := &http.Transport{DialContext: dial}
|
||||||
|
forward := &httputil.ReverseProxy{
|
||||||
|
Rewrite: func(r *httputil.ProxyRequest) {
|
||||||
|
r.Out.URL.Scheme = "http"
|
||||||
|
r.Out.URL.Host = "docker"
|
||||||
|
},
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
proxy := &DockerProxy{Socket: socket}
|
||||||
|
streams, cancelStreams := context.WithCancel(context.Background())
|
||||||
|
creates, cancelCreates := context.WithCancel(context.Background())
|
||||||
|
var admission sync.Mutex
|
||||||
|
var handlers sync.WaitGroup
|
||||||
|
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, ConnContext: func(ctx context.Context, conn net.Conn) context.Context {
|
||||||
|
return context.WithValue(ctx, dockerProxyConnKey{}, conn)
|
||||||
|
}, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admission.Lock()
|
||||||
|
if streams.Err() != nil {
|
||||||
|
admission.Unlock()
|
||||||
|
http.Error(w, "docker proxy is closing", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlers.Add(1)
|
||||||
|
admission.Unlock()
|
||||||
|
defer handlers.Done()
|
||||||
|
creating := r.Method == http.MethodPost && createPath.MatchString(r.URL.Path)
|
||||||
|
parent, lifetime := r.Context(), streams
|
||||||
|
if creating {
|
||||||
|
parent, lifetime = context.WithoutCancel(parent), creates
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(parent)
|
||||||
|
defer cancel()
|
||||||
|
stop := context.AfterFunc(lifetime, func() {
|
||||||
|
cancel()
|
||||||
|
if !creating {
|
||||||
|
if conn, ok := parent.Value(dockerProxyConnKey{}).(net.Conn); ok {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
if creating {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
|
||||||
|
mounts, _ := proxy.mounts.Load().(map[string]string)
|
||||||
|
if err := rewriteCreate(r, job, mounts); err != nil {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
|
||||||
|
status = http.StatusRequestEntityTooLarge
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if r.Method == http.MethodPost && rawStreamPath.MatchString(r.URL.Path) {
|
||||||
|
tunnel(w, r, dial, forward)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
forward.ServeHTTP(w, r)
|
||||||
|
})}
|
||||||
|
served := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(served)
|
||||||
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
proxy.close = func(ctx context.Context) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
admission.Lock()
|
||||||
|
listenerErr := listener.Close()
|
||||||
|
cancelStreams()
|
||||||
|
admission.Unlock()
|
||||||
|
<-served
|
||||||
|
shutdownErr := server.Shutdown(ctx)
|
||||||
|
cancelCreates()
|
||||||
|
serverErr := server.Close()
|
||||||
|
handlers.Wait()
|
||||||
|
transport.CloseIdleConnections()
|
||||||
|
return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
|
||||||
|
}
|
||||||
|
return proxy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteCreate(r *http.Request, job string, mounts map[string]string) error {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(body)) == 0 {
|
||||||
|
body = []byte("{}")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
var config struct{ Labels map[string]string }
|
||||||
|
if err := json.Unmarshal(body, &fields); err != nil {
|
||||||
|
return fmt.Errorf("invalid create request: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &config); err != nil {
|
||||||
|
return fmt.Errorf("invalid create labels: %w", err)
|
||||||
|
}
|
||||||
|
if fields == nil {
|
||||||
|
fields = make(map[string]json.RawMessage)
|
||||||
|
}
|
||||||
|
if len(mounts) > 0 && !hasAmbiguousFields(body) {
|
||||||
|
translateBinds(fields, createPath.FindStringSubmatch(r.URL.Path)[2], mounts)
|
||||||
|
}
|
||||||
|
maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
|
||||||
|
return strings.EqualFold(name, "Labels")
|
||||||
|
})
|
||||||
|
if config.Labels == nil {
|
||||||
|
config.Labels = make(map[string]string)
|
||||||
|
}
|
||||||
|
config.Labels[jobLabel] = job
|
||||||
|
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if body, err = json.Marshal(fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
r.ContentLength = int64(len(body))
|
||||||
|
r.TransferEncoding = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func translateBinds(fields map[string]json.RawMessage, kind string, mounts map[string]string) {
|
||||||
|
cleanedSource := func(source string) string {
|
||||||
|
cleaned := path.Clean(source)
|
||||||
|
if target := jobMount(cleaned, mounts); mounts[target] != "" {
|
||||||
|
return mounts[target] + cleaned[len(target):]
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
spelledSource := func(source string) string { // dockerd checks these as spelled
|
||||||
|
target := jobMount(path.Clean(source), mounts)
|
||||||
|
if rest, spelled := strings.CutPrefix(source, target); mounts[target] != "" && spelled && (rest == "" || rest[0] == '/') && filepath.IsLocal("."+rest) {
|
||||||
|
return mounts[target] + rest
|
||||||
|
}
|
||||||
|
return cleanedSource(source)
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "volumes":
|
||||||
|
var driver string
|
||||||
|
var options map[string]any
|
||||||
|
decodeField(fields, "Driver", &driver)
|
||||||
|
if key := decodeField(fields, "DriverOpts", &options); options != nil {
|
||||||
|
translateDevice(driver, options, spelledSource)
|
||||||
|
if encoded, err := json.Marshal(options); err == nil {
|
||||||
|
fields[key] = encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "containers":
|
||||||
|
var hostConfig map[string]any
|
||||||
|
key := decodeField(fields, "HostConfig", &hostConfig)
|
||||||
|
binds, _ := field(hostConfig, "Binds").([]any)
|
||||||
|
for i, bind := range binds {
|
||||||
|
bind, _ := bind.(string)
|
||||||
|
source, target, _ := strings.Cut(bind, ":")
|
||||||
|
if translated := cleanedSource(source); strings.HasPrefix(target, "/") && !strings.Contains(translated, ":") {
|
||||||
|
binds[i] = translated + ":" + target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
specs, _ := field(hostConfig, "Mounts").([]any)
|
||||||
|
for _, spec := range specs {
|
||||||
|
spec, _ := spec.(map[string]any)
|
||||||
|
switch field(spec, "Type") {
|
||||||
|
case "bind":
|
||||||
|
if source, ok := field(spec, "Source").(string); ok {
|
||||||
|
spec["Source"] = spelledSource(source)
|
||||||
|
}
|
||||||
|
case "volume":
|
||||||
|
volumeOptions, _ := field(spec, "VolumeOptions").(map[string]any)
|
||||||
|
driverConfig, _ := field(volumeOptions, "DriverConfig").(map[string]any)
|
||||||
|
translateDevice(field(driverConfig, "Name"), field(driverConfig, "Options"), spelledSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if encoded, err := json.Marshal(hostConfig); err == nil && hostConfig != nil {
|
||||||
|
fields[key] = encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeField(fields map[string]json.RawMessage, name string, value any) string {
|
||||||
|
for key, raw := range fields {
|
||||||
|
if strings.EqualFold(key, name) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.UseNumber()
|
||||||
|
_ = decoder.Decode(value) // wrong types stay unset for dockerd to reject
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func translateDevice(driver, options any, translate func(string) string) {
|
||||||
|
optionMap, _ := options.(map[string]any)
|
||||||
|
device, ok := optionMap["device"].(string)
|
||||||
|
flags, _ := optionMap["o"].(string)
|
||||||
|
tokens := strings.Split(flags, ",")
|
||||||
|
local := driver == nil || driver == "" || driver == "local"
|
||||||
|
if ok && local && (slices.Contains(tokens, "bind") || slices.Contains(tokens, "rbind")) && !slices.Contains(tokens, "remount") {
|
||||||
|
optionMap["device"] = translate(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// field also renames the matched key to name.
|
||||||
|
func field(object map[string]any, name string) any {
|
||||||
|
for key, value := range object {
|
||||||
|
if strings.EqualFold(key, name) {
|
||||||
|
delete(object, key)
|
||||||
|
object[name] = value
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
requestFields = []string{"hostconfig", "driver", "driveropts"}
|
||||||
|
asciiFolds = strings.NewReplacer("ſ", "s", "K", "k") // the non-ASCII runes strings.EqualFold matches to ASCII letters
|
||||||
|
)
|
||||||
|
|
||||||
|
// dockerd settles repeated names by order, which re-encoding loses.
|
||||||
|
func hasAmbiguousFields(body []byte) bool {
|
||||||
|
type frame struct {
|
||||||
|
names map[string]bool
|
||||||
|
key string
|
||||||
|
expectKey bool
|
||||||
|
nested bool
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
decoder.UseNumber()
|
||||||
|
stack := []*frame{{}}
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return !errors.Is(err, io.EOF)
|
||||||
|
}
|
||||||
|
top := stack[len(stack)-1]
|
||||||
|
if name, ok := token.(string); ok && top.expectKey {
|
||||||
|
top.key, top.expectKey = strings.ToLower(asciiFolds.Replace(name)), false
|
||||||
|
if top.names[top.key] && (top.nested || slices.Contains(requestFields, top.key)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
top.names[top.key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
top.expectKey = top.names != nil
|
||||||
|
nested := top.nested || len(stack) == 2 && slices.Contains(requestFields, top.key)
|
||||||
|
switch token {
|
||||||
|
case json.Delim('{'):
|
||||||
|
stack = append(stack, &frame{names: map[string]bool{}, expectKey: true, nested: nested})
|
||||||
|
case json.Delim('['):
|
||||||
|
stack = append(stack, &frame{nested: nested})
|
||||||
|
case json.Delim('}'), json.Delim(']'):
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobMount returns "" also for a path already naming a daemon source.
|
||||||
|
func jobMount(source string, mounts map[string]string) string {
|
||||||
|
target := ""
|
||||||
|
for destination, daemonSource := range mounts {
|
||||||
|
if daemonSource != "" && (source == daemonSource || strings.HasPrefix(source, daemonSource+"/")) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(destination) > len(target) && (source == destination || strings.HasPrefix(source, destination+"/")) {
|
||||||
|
target = destination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
type dockerProxyConnKey struct{}
|
||||||
|
|
||||||
|
type dockerProxyResponse struct {
|
||||||
|
response *http.Response
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r dockerProxyResponse) RoundTrip(_ *http.Request) (*http.Response, error) {
|
||||||
|
return r.response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
|
||||||
|
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error), forward *httputil.ReverseProxy) {
|
||||||
|
upstream, err := dial(r.Context(), "", "")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer upstream.Close()
|
||||||
|
stop := context.AfterFunc(r.Context(), func() { _ = upstream.Close() })
|
||||||
|
defer stop()
|
||||||
|
if err := r.Write(upstream); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader := bufio.NewReader(upstream)
|
||||||
|
var response *http.Response
|
||||||
|
for {
|
||||||
|
response, err = http.ReadResponse(reader, r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if response.StatusCode >= 200 || response.StatusCode == http.StatusSwitchingProtocols {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
maps.Copy(w.Header(), response.Header)
|
||||||
|
w.WriteHeader(response.StatusCode)
|
||||||
|
clear(w.Header())
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = upstream.Close()
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}()
|
||||||
|
mediaType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||||
|
if response.StatusCode != http.StatusSwitchingProtocols && (response.StatusCode != http.StatusOK || mediaType != "application/vnd.docker.raw-stream") {
|
||||||
|
ordinary := *forward
|
||||||
|
ordinary.Transport = dockerProxyResponse{response: response}
|
||||||
|
ordinary.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downstream, buffered, err := http.NewResponseController(w).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer downstream.Close()
|
||||||
|
if _, err := fmt.Fprintf(buffered, "%s %s\r\n", response.Proto, response.Status); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := response.Header.Write(buffered); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := buffered.WriteString("\r\n"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := buffered.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
if _, err := io.Copy(upstream, io.MultiReader(io.LimitReader(buffered, int64(buffered.Reader.Buffered())), downstream)); err != nil { // Bypass net/http after the prefix so stdin EOF preserves output.
|
||||||
|
_ = upstream.Close()
|
||||||
|
} else if writer, ok := upstream.(interface{ CloseWrite() error }); ok {
|
||||||
|
_ = writer.CloseWrite()
|
||||||
|
} else {
|
||||||
|
_ = upstream.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, _ = io.Copy(downstream, reader)
|
||||||
|
_ = downstream.Close()
|
||||||
|
_ = upstream.Close()
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveDockerJobResources(ctx context.Context, job string) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
return removeLabelled(ctx, cli, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
filters := make(client.Filters).Add("label", jobLabel+"="+job)
|
||||||
|
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
||||||
|
errs := []error{err}
|
||||||
|
for _, c := range containers.Items {
|
||||||
|
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
|
||||||
|
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
|
||||||
|
}
|
||||||
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
||||||
|
errs = append(errs, err)
|
||||||
|
for _, n := range networks.Items {
|
||||||
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); n.Scope == "swarm" && cerrdefs.IsInvalidArgument(err) { // swarm refuses while a service or its tasks use it
|
||||||
|
logger.Infof("keeping network %s, a swarm service still uses it", n.Name)
|
||||||
|
} else if err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
|
||||||
|
errs = append(errs, err)
|
||||||
|
for _, v := range volumes.Items {
|
||||||
|
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
|
"github.com/moby/moby/api/types/image"
|
||||||
|
"github.com/moby/moby/api/types/mount"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
|
"github.com/moby/moby/api/types/volume"
|
||||||
|
mobyclient "github.com/moby/moby/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *mockDockerClient) VolumeList(ctx context.Context, opts mobyclient.VolumeListOptions) (mobyclient.VolumeListResult, error) {
|
||||||
|
args := m.Called(ctx, opts)
|
||||||
|
return args.Get(0).(mobyclient.VolumeListResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) VolumeRemove(ctx context.Context, id string, opts mobyclient.VolumeRemoveOptions) (mobyclient.VolumeRemoveResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.VolumeRemoveResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unix socket paths are limited to about a hundred bytes, which the default macOS TMPDIR exceeds
|
||||||
|
func shortTempDir(t testing.TB) string {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("TMPDIR", "/tmp")
|
||||||
|
return t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
|
||||||
|
t.Helper()
|
||||||
|
host := cli.DaemonHost()
|
||||||
|
if !strings.HasPrefix(host, "unix://") {
|
||||||
|
t.Skipf("skipping: daemon at %s is not a unix socket", host)
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(host, "unix://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerProxy(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Unix socket ownership is unavailable on Windows")
|
||||||
|
}
|
||||||
|
bodies := make(chan []byte, 8)
|
||||||
|
createStarted, releaseCreate := make(chan struct{}), make(chan struct{})
|
||||||
|
streamsDone := make(chan struct{}, 2)
|
||||||
|
daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
|
||||||
|
listener, err := net.Listen("unix", daemonSocket)
|
||||||
|
require.NoError(t, err)
|
||||||
|
daemon := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/create"):
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if r.URL.RawQuery == "wait" {
|
||||||
|
close(createStarted)
|
||||||
|
<-releaseCreate
|
||||||
|
}
|
||||||
|
bodies <- body
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
case r.URL.Path == "/exec/detached/start" || r.URL.Path == "/exec/error/start":
|
||||||
|
if r.URL.Path == "/exec/error/start" {
|
||||||
|
w.Header().Set("Content-Type", "application/vnd.docker.raw-stream")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("ordinary"))
|
||||||
|
case rawStreamPath.MatchString(r.URL.Path) || r.URL.Path == "/session":
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
conn, buffered, err := w.(http.Hijacker).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if r.Header.Get("Upgrade") != "" {
|
||||||
|
_, _ = conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nready\n"))
|
||||||
|
} else {
|
||||||
|
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\nready\n"))
|
||||||
|
}
|
||||||
|
input, _ := io.ReadAll(buffered)
|
||||||
|
_, _ = conn.Write(append([]byte("final:"), input...))
|
||||||
|
streamsDone <- struct{}{}
|
||||||
|
default:
|
||||||
|
w.Header().Set("Api-Version", "1.47")
|
||||||
|
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
go func() { _ = daemon.Serve(listener) }()
|
||||||
|
t.Cleanup(func() { _ = daemon.Close() })
|
||||||
|
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
|
||||||
|
proxy.SetMounts(map[string]string{"/workspace/o/r": "/volumes/job/_data", "/workspace/o/r/tmp": "", "/var/run/docker.sock": "/tmp/p/docker.sock", "/volumes": "/daemon/volumes"})
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket)
|
||||||
|
}}}
|
||||||
|
t.Cleanup(client.CloseIdleConnections)
|
||||||
|
dialProxy := func(t *testing.T) *net.UnixConn {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: proxy.Socket, Net: "unix"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
startStream := func(t *testing.T, path, upgrade string) (*net.UnixConn, *bufio.Reader) {
|
||||||
|
t.Helper()
|
||||||
|
conn := dialProxy(t)
|
||||||
|
_, err := fmt.Fprintf(conn, "POST %s HTTP/1.1\r\nHost: docker\r\n%sContent-Length: 2\r\n\r\n{}input", path, upgrade)
|
||||||
|
require.NoError(t, err)
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
resp, err := http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
})
|
||||||
|
prefix, err := reader.ReadString('\n')
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "ready\n", prefix)
|
||||||
|
return conn, reader
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("labels creates", func(t *testing.T) {
|
||||||
|
for _, testCase := range []struct{ path, body, want string }{
|
||||||
|
{
|
||||||
|
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
|
||||||
|
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"Binds":["/workspace/o/r:/src:ro","/workspace/o/r/../r/sub/:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2"}}`,
|
||||||
|
`{"HostConfig":{"Binds":["/volumes/job/_data:/src:ro","/volumes/job/_data/sub:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2","com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/var/run/docker.sock","Target":"/var/run/docker.sock"},{"type":"bind","source":"/workspace/o/r/sub/","target":"/m"},{"Type":"bind","Source":"/workspace/o/r/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/workspace/o/r/../r","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]}}`,
|
||||||
|
`{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/tmp/p/docker.sock","Target":"/var/run/docker.sock"},{"Type":"bind","Source":"/volumes/job/_data/sub/","target":"/m"},{"Type":"bind","Source":"/volumes/job/_data/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/volumes/job/_data","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/workspace/o/r/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]}}`,
|
||||||
|
`{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/volumes/job/_data/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null}`,
|
||||||
|
`{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null,"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/networks/create", `{"Name":"n"}`,
|
||||||
|
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Name":"v","labels":null,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
||||||
|
`{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"},"DriverOpts":{"type":"none","o":"rbind,ro","device":"/volumes/job/_data/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"}}`,
|
||||||
|
`{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"}}`,
|
||||||
|
`{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", "",
|
||||||
|
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", "null",
|
||||||
|
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Labels":{"discard":"1"},"labels":null,"LABELS":{"own":"1"},"LABELS":{"extra":"2"}}`,
|
||||||
|
`{"Labels":{"own":"1","extra":"2","com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
assert.JSONEq(t, testCase.want, string(<-bodies), testCase.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("passes other requests through", func(t *testing.T) {
|
||||||
|
resp, err := client.Get("http://docker/v1.47/_ping")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
assert.Equal(t, "1.47", resp.Header.Get("Api-Version"))
|
||||||
|
assert.Equal(t, "OK GET /v1.47/_ping", string(body))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tunnels raw streams through stdin EOF", func(t *testing.T) {
|
||||||
|
for _, stream := range []struct{ path, upgrade string }{
|
||||||
|
{path: "/v1.47/exec/abc/start"},
|
||||||
|
{path: "/containers/abc/attach", upgrade: "Connection: Upgrade\r\nUpgrade: tcp\r\n"},
|
||||||
|
} {
|
||||||
|
conn, reader := startStream(t, stream.path, stream.upgrade)
|
||||||
|
require.NoError(t, conn.CloseWrite())
|
||||||
|
output, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "final:input", string(output))
|
||||||
|
<-streamsDone
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("detached and error responses retain keepalive label injection", func(t *testing.T) {
|
||||||
|
conn := dialProxy(t)
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
for endpoint, status := range map[string]int{"detached": http.StatusOK, "error": http.StatusBadRequest} {
|
||||||
|
_, err := fmt.Fprintf(conn, "POST /exec/%s/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}", endpoint)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, status, resp.StatusCode)
|
||||||
|
assert.Equal(t, "ordinary", string(body))
|
||||||
|
_, err = io.WriteString(conn, "POST /volumes/create HTTP/1.1\r\nHost: docker\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err = http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("close joins streams and admitted create", func(t *testing.T) {
|
||||||
|
t.Cleanup(func() { close(releaseCreate) })
|
||||||
|
_, raw := startStream(t, "/exec/live/start", "")
|
||||||
|
_, upgraded := startStream(t, "/session", "Connection: Upgrade\r\nUpgrade: tcp\r\n")
|
||||||
|
creator := dialProxy(t)
|
||||||
|
_, err := io.WriteString(creator, "POST /volumes/create?wait HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}")
|
||||||
|
require.NoError(t, err)
|
||||||
|
<-createStarted
|
||||||
|
require.NoError(t, creator.CloseWrite())
|
||||||
|
closed := make(chan error, 1)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go func() { closed <- proxy.Close(ctx) }()
|
||||||
|
for _, reader := range []*bufio.Reader{raw, upgraded} {
|
||||||
|
_, err := reader.ReadByte()
|
||||||
|
require.ErrorIs(t, err, io.EOF)
|
||||||
|
<-streamsDone
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-closed:
|
||||||
|
t.Fatalf("Close returned before admitted create settled: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
releaseCreate <- struct{}{}
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(creator), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
||||||
|
require.NoError(t, <-closed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
||||||
|
containerFailure := errors.New("container removal failed")
|
||||||
|
listFailure := errors.New("listing failed")
|
||||||
|
volumeFailure := errors.New("volume removal failed")
|
||||||
|
ctx := context.Background()
|
||||||
|
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
|
||||||
|
cli := &mockDockerClient{}
|
||||||
|
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
||||||
|
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
|
||||||
|
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once()
|
||||||
|
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||||
|
Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once()
|
||||||
|
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
|
||||||
|
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "stack_default", Scope: "swarm"}}}, listFailure).Once()
|
||||||
|
cli.On("NetworkRemove", ctx, "n1", mobyclient.NetworkRemoveOptions{}).Return(mobyclient.NetworkRemoveResult{}, cerrdefs.ErrInvalidArgument).Once()
|
||||||
|
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
|
||||||
|
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
|
||||||
|
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
|
||||||
|
|
||||||
|
err := removeLabelled(ctx, cli, "job-1")
|
||||||
|
require.ErrorIs(t, err, containerFailure)
|
||||||
|
require.ErrorIs(t, err, listFailure)
|
||||||
|
require.ErrorIs(t, err, volumeFailure)
|
||||||
|
require.NotErrorIs(t, err, cerrdefs.ErrInvalidArgument)
|
||||||
|
require.ErrorContains(t, err, "failed to remove container c1")
|
||||||
|
require.ErrorContains(t, err, "failed to remove volume app_data")
|
||||||
|
cli.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerProxyWithDaemon(t *testing.T) {
|
||||||
|
requireDocker(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
||||||
|
direct, err := GetDockerClient(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer direct.Close()
|
||||||
|
dir := shortTempDir(t)
|
||||||
|
seen, err := daemonSeesDir(ctx, direct, dir, dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("daemon sees the runner's filesystem: %v", seen)
|
||||||
|
seen, err = daemonSeesDir(ctx, direct, dir, shortTempDir(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, seen)
|
||||||
|
|
||||||
|
job := "proxy-test-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||||
|
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
|
||||||
|
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer viaProxy.Close()
|
||||||
|
|
||||||
|
created, err := viaProxy.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
|
||||||
|
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) })
|
||||||
|
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _, _ = direct.NetworkRemove(ctx, net.ID, mobyclient.NetworkRemoveOptions{}) })
|
||||||
|
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _, _ = direct.VolumeRemove(ctx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
|
||||||
|
|
||||||
|
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"own": "1", jobLabel: job}, inspected.Container.Config.Labels)
|
||||||
|
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, job, vol.Volume.Labels[jobLabel])
|
||||||
|
|
||||||
|
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
attached, err := viaProxy.ExecAttach(ctx, exec.ID, mobyclient.ExecAttachOptions{TTY: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = attached.Conn.Write([]byte("hello\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
echoed, err := attached.Reader.ReadString('\n')
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello\r\n", echoed)
|
||||||
|
attached.Close()
|
||||||
|
|
||||||
|
require.NoError(t, proxy.Close(ctx))
|
||||||
|
require.NoError(t, RemoveDockerJobResources(ctx, job))
|
||||||
|
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err))
|
||||||
|
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err))
|
||||||
|
_, err = direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkDockerProxy(b *testing.B) {
|
||||||
|
ctx := context.Background()
|
||||||
|
direct, err := GetDockerClient(ctx)
|
||||||
|
require.NoError(b, err)
|
||||||
|
defer direct.Close()
|
||||||
|
if _, err := direct.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||||
|
b.Skipf("docker daemon unreachable: %v", err)
|
||||||
|
}
|
||||||
|
proxy, err := StartDockerProxy(daemonSocketPath(b, direct), shortTempDir(b), "bench")
|
||||||
|
require.NoError(b, err)
|
||||||
|
defer func() { _ = proxy.Close(ctx) }()
|
||||||
|
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
|
||||||
|
require.NoError(b, err)
|
||||||
|
defer viaProxy.Close()
|
||||||
|
|
||||||
|
require.NoError(b, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
||||||
|
created, err := direct.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "600"}}})
|
||||||
|
require.NoError(b, err)
|
||||||
|
defer func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) }()
|
||||||
|
_, err = direct.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
||||||
|
require.NoError(b, err)
|
||||||
|
|
||||||
|
var archive bytes.Buffer
|
||||||
|
writer := tar.NewWriter(&archive)
|
||||||
|
payload := make([]byte, 64<<20)
|
||||||
|
require.NoError(b, writer.WriteHeader(&tar.Header{Name: "blob", Mode: 0o600, Size: int64(len(payload))}))
|
||||||
|
_, _ = writer.Write(payload)
|
||||||
|
require.NoError(b, writer.Close())
|
||||||
|
|
||||||
|
for name, cli := range map[string]mobyclient.APIClient{"direct": direct, "proxy": viaProxy} {
|
||||||
|
b.Run("ping/"+name, func(b *testing.B) {
|
||||||
|
for b.Loop() {
|
||||||
|
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
b.Run("copy64MiB/"+name, func(b *testing.B) {
|
||||||
|
b.SetBytes(int64(archive.Len()))
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := cli.CopyToContainer(ctx, created.ID, mobyclient.CopyToContainerOptions{DestinationPath: "/tmp", Content: bytes.NewReader(archive.Bytes())})
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type probeClient struct {
|
||||||
|
mobyclient.APIClient
|
||||||
|
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
||||||
|
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
||||||
|
stat func(string) error
|
||||||
|
mounts []container.MountPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerInspect(context.Context, string, mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||||
|
return mobyclient.ContainerInspectResult{Container: container.InspectResponse{Mounts: c.mounts}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) {
|
||||||
|
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "probe-image"}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
return c.create(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
return c.remove(ctx, id, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerStatPath(_ context.Context, _ string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
||||||
|
return mobyclient.ContainerStatPathResult{}, c.stat(opts.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSeesDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
markers := make(map[string]bool)
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
daemonDir string
|
||||||
|
removeErr error
|
||||||
|
seen bool
|
||||||
|
removed bool
|
||||||
|
}{
|
||||||
|
{name: "cleanup after cancellation", daemonDir: dir, seen: true, removed: true},
|
||||||
|
{name: "daemon without the directory", daemonDir: filepath.Join(dir, "missing")},
|
||||||
|
{name: "daemon with a different directory at that path", daemonDir: t.TempDir(), removed: true},
|
||||||
|
{name: "cleanup error after cancellation", daemonDir: dir, removeErr: errors.New("cleanup failed"), removed: true},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
removed := false
|
||||||
|
cli := &probeClient{
|
||||||
|
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts)
|
||||||
|
cancel()
|
||||||
|
if _, err := os.Stat(testCase.daemonDir); err != nil {
|
||||||
|
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
|
||||||
|
},
|
||||||
|
stat: func(path string) error {
|
||||||
|
marker := strings.TrimPrefix(path, "/gitea-runner-probe/")
|
||||||
|
assert.False(t, markers[marker])
|
||||||
|
markers[marker] = true
|
||||||
|
if _, err := os.Stat(filepath.Join(testCase.daemonDir, marker)); err != nil {
|
||||||
|
return cerrdefs.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
remove: func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
removed = true
|
||||||
|
assert.Equal(t, "probe", id)
|
||||||
|
assert.Equal(t, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, opts)
|
||||||
|
require.NoError(t, ctx.Err())
|
||||||
|
_, bounded := ctx.Deadline()
|
||||||
|
assert.True(t, bounded)
|
||||||
|
return mobyclient.ContainerRemoveResult{}, testCase.removeErr
|
||||||
|
},
|
||||||
|
}
|
||||||
|
seen, err := daemonSeesDir(ctx, cli, dir, testCase.daemonDir)
|
||||||
|
require.ErrorIs(t, err, testCase.removeErr)
|
||||||
|
assert.Equal(t, testCase.seen, seen)
|
||||||
|
assert.Equal(t, testCase.removed, removed)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerContainerWorkdir(t *testing.T) {
|
||||||
|
workdir := t.TempDir()
|
||||||
|
t.Chdir(workdir)
|
||||||
|
volume := container.MountPoint{Type: mount.TypeVolume, Source: "/daemon/_data", Destination: filepath.Dir(workdir)}
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
mount container.MountPoint
|
||||||
|
foreign bool
|
||||||
|
daemonDir string
|
||||||
|
}{
|
||||||
|
{name: "volume above the working directory", mount: volume, daemonDir: filepath.Join(volume.Source, filepath.Base(workdir))},
|
||||||
|
{name: "tmpfs", mount: container.MountPoint{Type: mount.TypeTmpfs, Destination: volume.Destination}},
|
||||||
|
{name: "hostname of another container", mount: volume, foreign: true},
|
||||||
|
} {
|
||||||
|
cli := &probeClient{
|
||||||
|
mounts: []container.MountPoint{testCase.mount},
|
||||||
|
stat: func(path string) error {
|
||||||
|
if _, err := os.Stat(path); err != nil || testCase.foreign {
|
||||||
|
return cerrdefs.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dir, daemonDir := runnerContainerWorkdir(t.Context(), cli)
|
||||||
|
assert.Equal(t, testCase.daemonDir, daemonDir, testCase.name)
|
||||||
|
assert.Equal(t, testCase.daemonDir != "", dir == workdir, testCase.name)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(workdir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyDockerSocketPermissions(daemonSocket, socket string, info os.FileInfo) error {
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("docker socket ownership is unavailable")
|
||||||
|
}
|
||||||
|
groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid))
|
||||||
|
if groupErr != nil && (!errors.Is(groupErr, fs.ErrPermission) || int(stat.Uid) != os.Geteuid()) {
|
||||||
|
return groupErr
|
||||||
|
}
|
||||||
|
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := uint16(info.Mode().Perm())
|
||||||
|
if err := os.Chmod(socket, fs.FileMode(mode)); err != nil || groupErr == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if size, err := unix.Getxattr(daemonSocket, "system.posix_acl_access", nil); err == nil && size > 0 {
|
||||||
|
return groupErr
|
||||||
|
}
|
||||||
|
owner, group, other := mode>>6, mode>>3&7, mode&7
|
||||||
|
acl := binary.LittleEndian.AppendUint32(nil, 2)
|
||||||
|
for _, entry := range []struct {
|
||||||
|
tag, perm uint16
|
||||||
|
id uint32
|
||||||
|
}{{1, owner, math.MaxUint32}, {4, other, math.MaxUint32}, {8, group, stat.Gid}, {16, group | other, math.MaxUint32}, {32, other, math.MaxUint32}} {
|
||||||
|
acl = binary.LittleEndian.AppendUint32(binary.LittleEndian.AppendUint16(binary.LittleEndian.AppendUint16(acl, entry.tag), entry.perm), entry.id)
|
||||||
|
}
|
||||||
|
return unix.Setxattr(socket, "system.posix_acl_access", acl, 0) // names the group a rootless runner cannot chown to, its own group keeps what others had
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error {
|
||||||
|
return errors.New("docker socket ownership cannot be preserved on Windows")
|
||||||
|
}
|
||||||
+167
-185
@@ -14,7 +14,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -26,13 +28,13 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
|
||||||
"dario.cat/mergo"
|
"dario.cat/mergo"
|
||||||
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/cli/cli/connhelper"
|
"github.com/docker/cli/cli/connhelper"
|
||||||
"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"
|
||||||
"github.com/gobwas/glob"
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
@@ -57,7 +59,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.Options)
|
cf := createFlagsFromOptions(input.allOptions())
|
||||||
if cf.platform != "" {
|
if cf.platform != "" {
|
||||||
cr.input.Platform = cf.platform
|
cr.input.Platform = cf.platform
|
||||||
}
|
}
|
||||||
@@ -65,29 +67,6 @@ func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
|||||||
return cr
|
return cr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
|
|
||||||
return common.
|
|
||||||
NewDebugExecutor("docker network connect %s %s", name, cr.input.Name).
|
|
||||||
Then(
|
|
||||||
common.NewPipelineExecutor(
|
|
||||||
cr.connect(),
|
|
||||||
cr.connectToNetwork(name, cr.input.NetworkAliases),
|
|
||||||
).IfNot(common.Dryrun),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) connectToNetwork(name string, aliases []string) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
_, err := cr.cli.NetworkConnect(ctx, name, client.NetworkConnectOptions{
|
|
||||||
Container: cr.input.Name,
|
|
||||||
EndpointConfig: &network.EndpointSettings{
|
|
||||||
Aliases: aliases,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// supportsContainerImagePlatform reports whether the Docker server API version
|
// supportsContainerImagePlatform reports whether the Docker server API version
|
||||||
// is 1.41 and beyond
|
// is 1.41 and beyond
|
||||||
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) (bool, error) {
|
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) (bool, error) {
|
||||||
@@ -160,12 +139,12 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
|
|||||||
).IfNot(common.Dryrun)
|
).IfNot(common.Dryrun)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
|
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
|
||||||
cr.connect(),
|
cr.connect(),
|
||||||
cr.find(),
|
cr.find(),
|
||||||
cr.copyDir(destPath, srcPath, useGitIgnore),
|
cr.copyDir(destPath, srcPath, useGitIgnore, skipGitDir),
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
// If this fails, then folders have wrong permissions on non root container
|
// If this fails, then folders have wrong permissions on non root container
|
||||||
if cr.UID != 0 || cr.GID != 0 {
|
if cr.UID != 0 || cr.GID != 0 {
|
||||||
@@ -274,6 +253,31 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
|
|||||||
ID: inspect.ID,
|
ID: inspect.ID,
|
||||||
Health: HealthNone,
|
Health: HealthNone,
|
||||||
Ports: map[string]string{}, // an empty map, never null, in the expression context
|
Ports: map[string]string{}, // an empty map, never null, in the expression context
|
||||||
|
Mounts: make(map[string]string, len(inspect.Mounts)),
|
||||||
|
}
|
||||||
|
for _, mountPoint := range inspect.Mounts {
|
||||||
|
info.Mounts[mountPoint.Destination] = mountPoint.Source
|
||||||
|
}
|
||||||
|
if hostConfig := inspect.HostConfig; hostConfig != nil { // Mounts omits --tmpfs targets and subpaths
|
||||||
|
for target := range hostConfig.Tmpfs {
|
||||||
|
info.Mounts[path.Clean(target)] = ""
|
||||||
|
}
|
||||||
|
for _, spec := range hostConfig.Mounts {
|
||||||
|
subpath := ""
|
||||||
|
if spec.VolumeOptions != nil {
|
||||||
|
subpath = spec.VolumeOptions.Subpath
|
||||||
|
} else if spec.ImageOptions != nil {
|
||||||
|
subpath = spec.ImageOptions.Subpath
|
||||||
|
}
|
||||||
|
if target := path.Clean(spec.Target); subpath != "" && info.Mounts[target] != "" {
|
||||||
|
info.Mounts[target] = path.Join(info.Mounts[target], subpath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range []string{"/etc/hosts", "/etc/hostname", "/etc/resolv.conf"} { // specific to the job's network namespace
|
||||||
|
if _, mounted := info.Mounts[target]; !mounted {
|
||||||
|
info.Mounts[target] = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if state := inspect.State; state != nil {
|
if state := inspect.State; state != nil {
|
||||||
@@ -506,14 +510,11 @@ func (cr *containerReference) remove() common.Executor {
|
|||||||
RemoveVolumes: true,
|
RemoveVolumes: true,
|
||||||
Force: true,
|
Force: true,
|
||||||
})
|
})
|
||||||
switch {
|
if cerrdefs.IsConflict(err) {
|
||||||
case cerrdefs.IsConflict(err):
|
err = cr.waitForRemoval(ctx, idOrName)
|
||||||
// the daemon's own AutoRemove teardown is running, and it releases the volume
|
}
|
||||||
// references and the network endpoint only once it finishes
|
if err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
cr.waitForRemoval(ctx, idOrName)
|
return fmt.Errorf("failed to remove container %s: %w", idOrName, err)
|
||||||
case err != nil && !cerrdefs.IsNotFound(err):
|
|
||||||
logger.Error(fmt.Errorf("failed to remove container %s: %w", idOrName, err))
|
|
||||||
return nil // keep the id, the container is still there for a later Remove()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Removed container: %v", idOrName)
|
logger.Debugf("Removed container: %v", idOrName)
|
||||||
@@ -522,7 +523,7 @@ func (cr *containerReference) remove() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
|
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) error {
|
||||||
// per container, against the one minute the post-job executor allows for the whole
|
// per container, against the one minute the post-job executor allows for the whole
|
||||||
// cleanup, so a job with several services can spend most of that budget here
|
// cleanup, so a job with several services can spend most of that budget here
|
||||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
@@ -532,44 +533,49 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
|
|||||||
Condition: container.WaitConditionRemoved,
|
Condition: container.WaitConditionRemoved,
|
||||||
})
|
})
|
||||||
select {
|
select {
|
||||||
case <-waitResult.Result:
|
case result := <-waitResult.Result:
|
||||||
case <-waitResult.Error:
|
if result.Error != nil {
|
||||||
case <-ctx.Done():
|
return errors.New(result.Error.Message)
|
||||||
// the client delivers the result over an unbuffered channel, so leave a receiver
|
}
|
||||||
// behind or its goroutine parks on the send for the lifetime of the process
|
return nil
|
||||||
go func() {
|
case err := <-waitResult.Error:
|
||||||
select {
|
return err
|
||||||
case <-waitResult.Result:
|
|
||||||
case <-waitResult.Error:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
input := cr.input
|
options := cr.input.allOptions()
|
||||||
|
|
||||||
if input.Options == "" {
|
if 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(input.Options)
|
flags, copts, cf, err := parseContainerOptions(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'", input.Options, err)
|
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
|
// 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:
|
||||||
@@ -581,55 +587,65 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the `privileged` config has been disabled, `copts.privileged` need to be forced to false,
|
// If the `privileged` config has been disabled, `copts.privileged` need to be forced to false,
|
||||||
// even if the user specifies `--privileged` in the options string.
|
// even if the user specifies `--privileged` in the options string.
|
||||||
if !hostConfig.Privileged {
|
if !hostConfig.Privileged && copts.privileged {
|
||||||
|
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", "--privileged")
|
||||||
copts.privileged = false
|
copts.privileged = false
|
||||||
}
|
}
|
||||||
|
|
||||||
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'", input.Options, err)
|
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
||||||
|
}
|
||||||
|
// workflow aliases join the runner's own, deduped so a re-create cannot grow the input
|
||||||
|
for _, endpoint := range containerConfig.NetworkingConfig.EndpointsConfig {
|
||||||
|
for _, alias := range endpoint.Aliases {
|
||||||
|
if !slices.Contains(cr.input.NetworkAliases, alias) {
|
||||||
|
cr.input.NetworkAliases = append(cr.input.NetworkAliases, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea, forcing --privileged off is not enough, other options reach the host too
|
||||||
// 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 {
|
||||||
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig)
|
trusted, err := parseOptionsHostConfig(cr.input.RunnerOptions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
sanitizeOptionsHostConfig(logger, containerConfig.HostConfig, trusted)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
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'", input.Options, err)
|
return nil, nil, fmt.Errorf("cannot merge container.Config options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
logger.Debugf("Merged container.Config ==> %+v", config)
|
logger.Debugf("Merged container.Config ==> %+v", config)
|
||||||
|
|
||||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||||
|
|
||||||
|
cr.sanitizeConfig(ctx, nil, containerConfig.HostConfig) // a dropped volume must not displace the runner's own
|
||||||
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
||||||
binds := hostConfig.Binds
|
binds := hostConfig.Binds
|
||||||
mounts := hostConfig.Mounts
|
mounts := hostConfig.Mounts
|
||||||
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'", input.Options, err)
|
return nil, nil, fmt.Errorf("cannot merge container.HostConfig options: '%s': '%w'", 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.")
|
||||||
}
|
}
|
||||||
if len(copts.netMode.Value()) > 0 {
|
// the runner's own network mode was put into copts above, so ask the flags instead
|
||||||
|
if flags.Changed("network") || flags.Changed("net") {
|
||||||
logger.Warn("--network and --net in the options will be ignored.")
|
logger.Warn("--network and --net in the options will be ignored.")
|
||||||
}
|
}
|
||||||
hostConfig.NetworkMode = networkMode
|
hostConfig.NetworkMode = networkMode
|
||||||
@@ -944,43 +960,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
if cr.id == "" {
|
|
||||||
return cr.missingContainerError("copy to %s", destPath)
|
|
||||||
}
|
|
||||||
// Mkdir, with a path relative to the DestinationPath ("/") below. Docker 29.5+
|
|
||||||
// rejects absolute tar entry names with "path escapes from parent".
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
tw := tar.NewWriter(buf)
|
|
||||||
_ = tw.WriteHeader(&tar.Header{
|
|
||||||
Name: strings.TrimPrefix(destPath, "/"),
|
|
||||||
Mode: 0o777,
|
|
||||||
Typeflag: tar.TypeDir,
|
|
||||||
})
|
|
||||||
tw.Close()
|
|
||||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
|
||||||
DestinationPath: "/",
|
|
||||||
Content: buf,
|
|
||||||
})
|
|
||||||
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 {
|
|
||||||
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)
|
||||||
@@ -1021,10 +1001,10 @@ 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,
|
SkipGitDir: skipGitDir,
|
||||||
Handler: &filecollector.TarCollector{
|
Handler: &filecollector.TarCollector{
|
||||||
TarWriter: tw,
|
TarWriter: tw,
|
||||||
UID: cr.UID,
|
UID: cr.UID,
|
||||||
@@ -1172,74 +1152,64 @@ func (cr *containerReference) wait() common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// sanitizeOptionsHostConfig clears the HostConfig fields parsed from a
|
// sanitizeOptionsHostConfig takes back everything a workflow could escape the container with,
|
||||||
// workflow-controlled container.options string that could be used to escape the
|
// setting each field to trusted, which is what the runner's own options parse to on their own.
|
||||||
// container when privileged mode is disabled. It must only be called when the
|
// Only for unprivileged mode, since privileged mode grants host access anyway.
|
||||||
// runner has privileged mode turned off; with privileged mode enabled the
|
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig, trusted *container.HostConfig) {
|
||||||
// administrator has already opted into host access.
|
resetOption(logger, "--pid", &hostConfig.PidMode, trusted.PidMode)
|
||||||
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig *container.HostConfig) {
|
resetOption(logger, "--ipc", &hostConfig.IpcMode, trusted.IpcMode)
|
||||||
warn := func(option string) {
|
resetOption(logger, "--uts", &hostConfig.UTSMode, trusted.UTSMode)
|
||||||
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", option)
|
resetOption(logger, "--cgroupns", &hostConfig.CgroupnsMode, trusted.CgroupnsMode)
|
||||||
}
|
resetOption(logger, "--userns", &hostConfig.UsernsMode, trusted.UsernsMode) // --userns=host would undo the remapping the runner asked for
|
||||||
|
resetOption(logger, "--cap-add", &hostConfig.CapAdd, trusted.CapAdd)
|
||||||
|
resetOption(logger, "--security-opt", &hostConfig.SecurityOpt, trusted.SecurityOpt)
|
||||||
|
resetOption(logger, "--device", &hostConfig.Devices, trusted.Devices)
|
||||||
|
resetOption(logger, "--device-cgroup-rule", &hostConfig.DeviceCgroupRules, trusted.DeviceCgroupRules)
|
||||||
|
resetOption(logger, "--gpus", &hostConfig.DeviceRequests, trusted.DeviceRequests)
|
||||||
|
resetOption(logger, "--volumes-from", &hostConfig.VolumesFrom, trusted.VolumesFrom)
|
||||||
|
resetOption(logger, "--runtime", &hostConfig.Runtime, trusted.Runtime)
|
||||||
|
resetOption(logger, "--cgroup-parent", &hostConfig.CgroupParent, trusted.CgroupParent)
|
||||||
|
resetOption(logger, "--sysctl", &hostConfig.Sysctls, trusted.Sysctls)
|
||||||
|
resetOption(logger, "--isolation", &hostConfig.Isolation, trusted.Isolation) // windows: process isolation drops the hyper-v boundary
|
||||||
|
resetOption(logger, "--volume-driver", &hostConfig.VolumeDriver, trusted.VolumeDriver)
|
||||||
|
// systempaths=unconfined lands in these two rather than in SecurityOpt
|
||||||
|
resetOption(logger, "--security-opt", &hostConfig.MaskedPaths, trusted.MaskedPaths)
|
||||||
|
resetOption(logger, "--security-opt", &hostConfig.ReadonlyPaths, trusted.ReadonlyPaths)
|
||||||
|
|
||||||
if hostConfig.PidMode != "" {
|
// a driver mounts what it likes, e.g. local with device= binds any host path, which
|
||||||
warn("--pid")
|
// valid_volumes never gets to see
|
||||||
hostConfig.PidMode = ""
|
hostConfig.Mounts = slices.DeleteFunc(hostConfig.Mounts, func(mt mount.Mount) bool {
|
||||||
|
if mt.VolumeOptions == nil || mt.VolumeOptions.DriverConfig == nil ||
|
||||||
|
slices.ContainsFunc(trusted.Mounts, func(t mount.Mount) bool { return reflect.DeepEqual(t, mt) }) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
logger.Warnf("volume driver of %q in the workflow is not allowed when privileged mode is disabled and will be ignored", mt.Source)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetOption puts a field back to the runner's own value. It compares the values rather than
|
||||||
|
// the flags, so a field that more than one option feeds cannot slip through.
|
||||||
|
func resetOption[T any](logger logrus.FieldLogger, option string, field *T, trusted T) {
|
||||||
|
if reflect.DeepEqual(*field, trusted) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if hostConfig.IpcMode != "" {
|
logger.Warnf("container option %q in the workflow is not allowed when privileged mode is disabled and will be ignored", option)
|
||||||
warn("--ipc")
|
*field = trusted
|
||||||
hostConfig.IpcMode = ""
|
}
|
||||||
|
|
||||||
|
// parseOptionsHostConfig parses one options string on its own, to see what it alone asks for.
|
||||||
|
// Even "" goes through the parser, or its empty slices and maps would differ from a real parse.
|
||||||
|
func parseOptionsHostConfig(options string) (*container.HostConfig, error) {
|
||||||
|
flags, copts, _, err := parseContainerOptions(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if hostConfig.UTSMode != "" {
|
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
||||||
warn("--uts")
|
if err != nil {
|
||||||
hostConfig.UTSMode = ""
|
return nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
||||||
}
|
|
||||||
if hostConfig.CgroupnsMode != "" {
|
|
||||||
warn("--cgroupns")
|
|
||||||
hostConfig.CgroupnsMode = ""
|
|
||||||
}
|
|
||||||
// UsernsMode is set from the runner-controlled input; never let options
|
|
||||||
// override it (e.g. --userns=host disables user namespace remapping).
|
|
||||||
if hostConfig.UsernsMode != "" {
|
|
||||||
warn("--userns")
|
|
||||||
hostConfig.UsernsMode = ""
|
|
||||||
}
|
|
||||||
if len(hostConfig.CapAdd) > 0 {
|
|
||||||
warn("--cap-add")
|
|
||||||
hostConfig.CapAdd = nil
|
|
||||||
}
|
|
||||||
if len(hostConfig.SecurityOpt) > 0 {
|
|
||||||
warn("--security-opt")
|
|
||||||
hostConfig.SecurityOpt = nil
|
|
||||||
}
|
|
||||||
if len(hostConfig.Devices) > 0 {
|
|
||||||
warn("--device")
|
|
||||||
hostConfig.Devices = nil
|
|
||||||
}
|
|
||||||
if len(hostConfig.DeviceCgroupRules) > 0 {
|
|
||||||
warn("--device-cgroup-rule")
|
|
||||||
hostConfig.DeviceCgroupRules = nil
|
|
||||||
}
|
|
||||||
if len(hostConfig.DeviceRequests) > 0 {
|
|
||||||
warn("--gpus")
|
|
||||||
hostConfig.DeviceRequests = nil
|
|
||||||
}
|
|
||||||
if len(hostConfig.VolumesFrom) > 0 {
|
|
||||||
warn("--volumes-from")
|
|
||||||
hostConfig.VolumesFrom = nil
|
|
||||||
}
|
|
||||||
if hostConfig.Runtime != "" {
|
|
||||||
warn("--runtime")
|
|
||||||
hostConfig.Runtime = ""
|
|
||||||
}
|
|
||||||
if hostConfig.CgroupParent != "" {
|
|
||||||
warn("--cgroup-parent")
|
|
||||||
hostConfig.CgroupParent = ""
|
|
||||||
}
|
|
||||||
if len(hostConfig.Sysctls) > 0 {
|
|
||||||
warn("--sysctl")
|
|
||||||
hostConfig.Sysctls = nil
|
|
||||||
}
|
}
|
||||||
|
return containerConfig.HostConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
@@ -1280,6 +1250,12 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
}
|
}
|
||||||
hostConfig.Mounts = sanitizedMounts
|
hostConfig.Mounts = sanitizedMounts
|
||||||
} else {
|
} else {
|
||||||
|
for _, bind := range hostConfig.Binds {
|
||||||
|
logger.Warnf("[%s] is not a valid volume, will be ignored", bind)
|
||||||
|
}
|
||||||
|
for _, mt := range hostConfig.Mounts {
|
||||||
|
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
||||||
|
}
|
||||||
hostConfig.Binds = []string{}
|
hostConfig.Binds = []string{}
|
||||||
hostConfig.Mounts = []mount.Mount{}
|
hostConfig.Mounts = []mount.Mount{}
|
||||||
}
|
}
|
||||||
@@ -1287,6 +1263,12 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
return config, hostConfig
|
return config, hostConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string {
|
||||||
|
cr := &containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}
|
||||||
|
_, hostConfig := cr.sanitizeConfig(ctx, nil, &container.HostConfig{Binds: binds})
|
||||||
|
return hostConfig.Binds
|
||||||
|
}
|
||||||
|
|
||||||
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
||||||
func bindTarget(bind string) string {
|
func bindTarget(bind string) string {
|
||||||
parsed, err := loader.ParseVolume(bind)
|
parsed, err := loader.ParseVolume(bind)
|
||||||
@@ -1317,15 +1299,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
|
|||||||
|
|
||||||
type validVolumeMatcher struct {
|
type validVolumeMatcher struct {
|
||||||
allowAll bool
|
allowAll bool
|
||||||
named []glob.Glob
|
named []string
|
||||||
host []glob.Glob
|
host []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
ret := validVolumeMatcher{
|
ret := validVolumeMatcher{
|
||||||
named: make([]glob.Glob, 0, len(validVolumes)),
|
named: make([]string, 0, len(validVolumes)),
|
||||||
host: make([]glob.Glob, 0, len(validVolumes)),
|
host: make([]string, 0, len(validVolumes)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range validVolumes {
|
for _, v := range validVolumes {
|
||||||
@@ -1334,10 +1316,10 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !isHostVolumePattern(v) {
|
if !isHostVolumePattern(v) {
|
||||||
if g, err := glob.Compile(v); err != nil {
|
if doublestar.ValidatePattern(v) {
|
||||||
logger.Errorf("create glob from %s error: %v", v, err)
|
ret.named = append(ret.named, v)
|
||||||
} else {
|
} else {
|
||||||
ret.named = append(ret.named, g)
|
logger.Errorf("invalid volume pattern %s", v)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1346,10 +1328,10 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
|
|||||||
logger.Errorf("normalize volume pattern %s error: %v", v, err)
|
logger.Errorf("normalize volume pattern %s error: %v", v, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if g, err := glob.Compile(normalized); err != nil {
|
if doublestar.ValidatePathPattern(normalized) {
|
||||||
logger.Errorf("create glob from %s error: %v", normalized, err)
|
ret.host = append(ret.host, normalized)
|
||||||
} else {
|
} else {
|
||||||
ret.host = append(ret.host, g)
|
logger.Errorf("invalid volume pattern %s", normalized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,15 +1347,15 @@ func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, g := range m.host {
|
for _, pattern := range m.host {
|
||||||
if g.Match(normalized) {
|
if doublestar.PathMatchUnvalidated(pattern, normalized) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, g := range m.named {
|
for _, pattern := range m.named {
|
||||||
if g.Match(source) {
|
if doublestar.MatchUnvalidated(pattern, source) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+211
-263
@@ -5,7 +5,6 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
@@ -17,7 +16,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -149,12 +147,17 @@ 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 endlessReader struct {
|
type interruptReader struct {
|
||||||
io.Reader
|
started chan struct{}
|
||||||
|
interrupted chan struct{}
|
||||||
|
stopped chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r endlessReader) Read(_ []byte) (n int, err error) {
|
func (r *interruptReader) Read(_ []byte) (int, error) {
|
||||||
return 1, nil
|
close(r.started)
|
||||||
|
<-r.interrupted
|
||||||
|
close(r.stopped)
|
||||||
|
return 0, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockConn struct {
|
type mockConn struct {
|
||||||
@@ -174,16 +177,17 @@ 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", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
conn.On("Write", []byte{3}).
|
||||||
|
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{
|
||||||
@@ -200,11 +204,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)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
<-reader.started
|
||||||
|
|
||||||
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)
|
||||||
@@ -219,10 +223,8 @@ 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,
|
||||||
@@ -274,10 +276,8 @@ 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,131 +342,25 @@ func TestDockerWaitFailure(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerCopyTarStream(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
|
||||||
return opts.DestinationPath == "/" && opts.Content != nil
|
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
|
||||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
|
||||||
cr := &containerReference{
|
|
||||||
id: "123",
|
|
||||||
cli: client,
|
|
||||||
input: &NewContainerInput{
|
|
||||||
Image: "image",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
|
||||||
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker 29.5+ rejects absolute names in the mkdir tarball with
|
|
||||||
// "path escapes from parent", since it is extracted relative to "/".
|
|
||||||
func TestDockerCopyTarStreamMkdirEntryIsRelative(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var mkdirNames []string
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
|
||||||
if opts.DestinationPath != "/" || opts.Content == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
tr := tar.NewReader(opts.Content)
|
|
||||||
for {
|
|
||||||
hdr, err := tr.Next()
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
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 == "/var/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{"var/run/act"}, mkdirNames)
|
|
||||||
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
merr := errors.New("Failure")
|
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
|
||||||
return opts.DestinationPath == "/" && opts.Content != nil
|
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
|
||||||
cr := &containerReference{
|
|
||||||
id: "123",
|
|
||||||
cli: client,
|
|
||||||
input: &NewContainerInput{
|
|
||||||
Image: "image",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
|
||||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
merr := errors.New("Failure")
|
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
|
||||||
return opts.DestinationPath == "/" && opts.Content != nil
|
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
|
||||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
|
||||||
cr := &containerReference{
|
|
||||||
id: "123",
|
|
||||||
cli: client,
|
|
||||||
input: &NewContainerInput{
|
|
||||||
Image: "image",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
|
||||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
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) {
|
||||||
|
removeFailure := errors.New("driver failed to remove root filesystem")
|
||||||
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||||
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
err error
|
err error
|
||||||
wantWait bool
|
wantWait bool
|
||||||
wantFailure bool
|
waitErr error
|
||||||
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
||||||
|
{name: "wait canceled", err: cerrdefs.ErrConflict, wantWait: true, waitErr: context.Canceled, wantErr: context.Canceled},
|
||||||
|
{name: "removed during wait", err: cerrdefs.ErrConflict, wantWait: true, waitErr: cerrdefs.ErrNotFound},
|
||||||
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
||||||
{name: "removed cleanly", err: nil},
|
{name: "removed cleanly", err: nil},
|
||||||
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := test.NewNullLogger()
|
||||||
@@ -476,21 +370,24 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||||
if tc.wantWait {
|
if tc.wantWait {
|
||||||
removed := make(chan container.WaitResponse, 1)
|
removed := make(chan container.WaitResponse, 1)
|
||||||
removed <- container.WaitResponse{}
|
waitErrors := make(chan error, 1)
|
||||||
|
if tc.waitErr == nil {
|
||||||
|
removed <- container.WaitResponse{}
|
||||||
|
} else {
|
||||||
|
waitErrors <- tc.waitErr
|
||||||
|
}
|
||||||
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||||
Return(mobyclient.ContainerWaitResult{Result: removed})
|
Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
|
||||||
}
|
}
|
||||||
cr := &containerReference{id: "abc", cli: client}
|
cr := &containerReference{id: "abc", cli: client}
|
||||||
|
|
||||||
require.NoError(t, cr.remove()(ctx))
|
require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
|
||||||
// a failure keeps the id, so a later Remove() can retry it
|
if tc.wantErr != nil {
|
||||||
if tc.wantFailure {
|
|
||||||
assert.Equal(t, "abc", cr.id)
|
assert.Equal(t, "abc", cr.id)
|
||||||
assert.Len(t, hook.AllEntries(), 1)
|
|
||||||
} else {
|
} else {
|
||||||
assert.Empty(t, cr.id)
|
assert.Empty(t, cr.id)
|
||||||
assert.Empty(t, hook.AllEntries())
|
|
||||||
}
|
}
|
||||||
|
assert.Empty(t, hook.AllEntries())
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -581,8 +478,7 @@ func TestRejectsMissingContainer(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
||||||
}
|
}
|
||||||
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
check("copyDir", cr.copyDir("/var/run/act", "/src", false, false)(ctx))
|
||||||
check("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)
|
||||||
@@ -618,35 +514,6 @@ 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 first creates the destination directory by extracting a tar at "/",
|
|
||||||
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
|
|
||||||
// step that fails on the broken daemon.
|
|
||||||
err := rc.CopyTarStream(ctx, "/var/run/act/actions/", &bytes.Buffer{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type assert containerReference implements ExecutionsEnvironment
|
// Type assert containerReference implements ExecutionsEnvironment
|
||||||
var _ ExecutionsEnvironment = &containerReference{}
|
var _ ExecutionsEnvironment = &containerReference{}
|
||||||
|
|
||||||
@@ -693,11 +560,14 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
"shared_volume",
|
"shared_volume",
|
||||||
"/home/test/data",
|
"/home/test/data",
|
||||||
"/etc/conf.d/*.json",
|
"/etc/conf.d/*.json",
|
||||||
|
"/opt/**",
|
||||||
},
|
},
|
||||||
binds: []string{
|
binds: []string{
|
||||||
"shared_volume:/shared_volume",
|
"shared_volume:/shared_volume",
|
||||||
"/home/test/data:/test_data",
|
"/home/test/data:/test_data",
|
||||||
"/etc/conf.d/base.json:/config/base.json",
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"/etc/conf.d/sub/nested.json:/config/nested.json",
|
||||||
|
"/opt/app/cache:/cache",
|
||||||
"sql_data:/sql_data",
|
"sql_data:/sql_data",
|
||||||
"/secrets/keys:/keys",
|
"/secrets/keys:/keys",
|
||||||
},
|
},
|
||||||
@@ -705,12 +575,13 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
"shared_volume:/shared_volume",
|
"shared_volume:/shared_volume",
|
||||||
"/home/test/data:/test_data",
|
"/home/test/data:/test_data",
|
||||||
"/etc/conf.d/base.json:/config/base.json",
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"/opt/app/cache:/cache",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.desc, func(t *testing.T) {
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger, hook := test.NewNullLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
input: &NewContainerInput{
|
input: &NewContainerInput{
|
||||||
@@ -719,112 +590,139 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
||||||
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
||||||
|
assert.Len(t, hook.AllEntries(), len(tc.binds)-len(tc.expectedBinds)) // every drop is warned about
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A volume driver decides for itself what it mounts, e.g. the local driver with device= binds
|
||||||
|
// any host path, which valid_volumes never gets to see.
|
||||||
|
func TestMergeContainerConfigsDropsVolumeDriversFromWorkflows(t *testing.T) {
|
||||||
|
const escape = "--mount type=volume,src=job-escape,dst=/host,volume-driver=local,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=/"
|
||||||
|
|
||||||
|
hostConfig, _ := mergeOptions(t, "", escape+" --mount type=volume,src=job-plain,dst=/cache", false)
|
||||||
|
require.Len(t, hostConfig.Mounts, 1)
|
||||||
|
assert.Equal(t, "job-plain", hostConfig.Mounts[0].Source)
|
||||||
|
|
||||||
|
// the same mount from the runner's own options is the administrator's to make
|
||||||
|
hostConfig, _ = mergeOptions(t, escape, "", false)
|
||||||
|
require.Len(t, hostConfig.Mounts, 1)
|
||||||
|
assert.Equal(t, "job-escape", hostConfig.Mounts[0].Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both of these are read here, on the runner, so a workflow could read the runner's files
|
||||||
|
// and environment with them.
|
||||||
|
func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
|
||||||
|
hostFile := filepath.Join(t.TempDir(), "host.env")
|
||||||
|
require.NoError(t, os.WriteFile(hostFile, []byte("STOLEN=from-the-host\n"), 0o600))
|
||||||
|
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
||||||
|
|
||||||
|
for _, option := range []string{"--env-file " + hostFile, "--label-file " + hostFile} {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
|
||||||
|
|
||||||
|
_, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
|
require.ErrorContains(t, err, "not allowed in a workflow")
|
||||||
|
|
||||||
|
// the runner reading its own files is what those options are for
|
||||||
|
cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}}
|
||||||
|
_, _, err = cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a bare name is no longer resolved from the runner's environment, for either source
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
RunnerOptions: "--env RUNNER_SECRET",
|
||||||
|
WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value",
|
||||||
|
}}
|
||||||
|
|
||||||
|
config, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger, _ := test.NewNullLogger()
|
||||||
|
|
||||||
dangerous := func() *container.HostConfig {
|
// every field the sanitizer resets, so a reset dropped in a refactor fails here
|
||||||
return &container.HostConfig{
|
hostConfig := &container.HostConfig{
|
||||||
PidMode: "host",
|
PidMode: "host",
|
||||||
IpcMode: "host",
|
IpcMode: "host",
|
||||||
UTSMode: "host",
|
UTSMode: "host",
|
||||||
CgroupnsMode: "host",
|
CgroupnsMode: "host",
|
||||||
UsernsMode: "host",
|
UsernsMode: "host",
|
||||||
CapAdd: []string{"ALL"},
|
CapAdd: []string{"ALL"},
|
||||||
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
||||||
VolumesFrom: []string{"other"},
|
VolumesFrom: []string{"other"},
|
||||||
Runtime: "runc",
|
Runtime: "runc",
|
||||||
Resources: container.Resources{
|
Isolation: "process",
|
||||||
CgroupParent: "/custom",
|
VolumeDriver: "rogue",
|
||||||
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
MaskedPaths: []string{},
|
||||||
DeviceCgroupRules: []string{"a *:* rwm"},
|
ReadonlyPaths: []string{},
|
||||||
},
|
CgroupParent: "/custom",
|
||||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
||||||
}
|
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||||
|
DeviceRequests: []container.DeviceRequest{{Count: -1, Capabilities: [][]string{{"gpu"}}}},
|
||||||
|
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
hostConfig := dangerous()
|
sanitizeOptionsHostConfig(logger, hostConfig, &container.HostConfig{})
|
||||||
sanitizeOptionsHostConfig(logger, hostConfig)
|
|
||||||
|
|
||||||
assert.Empty(t, string(hostConfig.PidMode))
|
assert.Equal(t, &container.HostConfig{}, hostConfig)
|
||||||
assert.Empty(t, string(hostConfig.IpcMode))
|
}
|
||||||
assert.Empty(t, string(hostConfig.UTSMode))
|
|
||||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
// mergeOptions merges both option sources into a bare container, returning the result and its log.
|
||||||
assert.Empty(t, string(hostConfig.UsernsMode))
|
func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *test.Hook) {
|
||||||
assert.Empty(t, hostConfig.CapAdd)
|
t.Helper()
|
||||||
assert.Empty(t, hostConfig.SecurityOpt)
|
logger, hook := test.NewNullLogger()
|
||||||
assert.Empty(t, hostConfig.Devices)
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
assert.Empty(t, hostConfig.DeviceCgroupRules)
|
RunnerOptions: runnerOptions,
|
||||||
assert.Empty(t, hostConfig.VolumesFrom)
|
WorkflowOptions: workflowOptions,
|
||||||
assert.Empty(t, hostConfig.Runtime)
|
NetworkMode: "bridge",
|
||||||
assert.Empty(t, hostConfig.CgroupParent)
|
UsernsMode: "private",
|
||||||
assert.Empty(t, hostConfig.Sysctls)
|
ValidVolumes: []string{"**"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
|
||||||
|
Privileged: privileged,
|
||||||
|
UsernsMode: container.UsernsMode("private"),
|
||||||
|
NetworkMode: container.NetworkMode("bridge"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return hostConfig, hook
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
||||||
// OS-independent options only: --device parsing requires a linux/windows
|
// OS-independent options only, --device and --gpus need a linux/windows server OS
|
||||||
// server OS, which is not guaranteed for the test host.
|
|
||||||
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||||
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||||
"--security-opt apparmor=unconfined --volumes-from other " +
|
"--security-opt apparmor=unconfined --volumes-from other --isolation process " +
|
||||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
||||||
|
|
||||||
t.Run("unprivileged strips host-escape options", func(t *testing.T) {
|
// whatever the workflow adds, an unprivileged container comes out exactly as the runner's
|
||||||
logger, _ := test.NewNullLogger()
|
// own options alone describe it, field for field
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
for _, runnerOptions := range []string{"--shm-size 1g", dangerousOptions, "--cap-add SYS_ADMIN --security-opt seccomp=unconfined"} {
|
||||||
cr := &containerReference{
|
runnerOnly, _ := mergeOptions(t, runnerOptions, "", false)
|
||||||
input: &NewContainerInput{
|
withWorkflow, _ := mergeOptions(t, runnerOptions, dangerousOptions, false)
|
||||||
Options: dangerousOptions,
|
|
||||||
NetworkMode: "bridge",
|
|
||||||
UsernsMode: "private",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
assert.Equal(t, runnerOnly, withWorkflow, "runner options: %q", runnerOptions)
|
||||||
Privileged: false,
|
}
|
||||||
UsernsMode: container.UsernsMode("private"),
|
|
||||||
NetworkMode: container.NetworkMode("bridge"),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.False(t, hostConfig.Privileged)
|
// the same options from the runner reach the daemon, even --userns, which no workflow may set
|
||||||
assert.Empty(t, string(hostConfig.PidMode))
|
kept, _ := mergeOptions(t, dangerousOptions, "", false)
|
||||||
assert.Empty(t, string(hostConfig.IpcMode))
|
assert.Equal(t, "host", string(kept.PidMode))
|
||||||
assert.Empty(t, string(hostConfig.UTSMode))
|
assert.Equal(t, []string{"ALL"}, kept.CapAdd)
|
||||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
assert.Equal(t, "runc", kept.Runtime)
|
||||||
// UsernsMode must keep the runner-controlled value, not the one from options.
|
assert.Equal(t, "host", string(kept.UsernsMode))
|
||||||
assert.Equal(t, "private", string(hostConfig.UsernsMode))
|
assert.False(t, kept.Privileged)
|
||||||
assert.Empty(t, hostConfig.CapAdd)
|
|
||||||
assert.Empty(t, hostConfig.SecurityOpt)
|
|
||||||
assert.Empty(t, hostConfig.VolumesFrom)
|
|
||||||
assert.Empty(t, hostConfig.Runtime)
|
|
||||||
assert.Empty(t, hostConfig.CgroupParent)
|
|
||||||
assert.Empty(t, hostConfig.Sysctls)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("privileged preserves options", func(t *testing.T) {
|
// privileged is the administrator opting in, so the workflow's options are honored
|
||||||
logger, _ := test.NewNullLogger()
|
privileged, _ := mergeOptions(t, "", dangerousOptions, true)
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
assert.Equal(t, "host", string(privileged.PidMode))
|
||||||
cr := &containerReference{
|
assert.Equal(t, []string{"ALL"}, privileged.CapAdd)
|
||||||
input: &NewContainerInput{
|
assert.Equal(t, []string{"seccomp=unconfined", "apparmor=unconfined"}, privileged.SecurityOpt)
|
||||||
Options: "--pid=host --cap-add=ALL --security-opt seccomp=unconfined",
|
|
||||||
NetworkMode: "bridge",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
|
||||||
Privileged: true,
|
|
||||||
NetworkMode: container.NetworkMode("bridge"),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "host", string(hostConfig.PidMode))
|
|
||||||
assert.Equal(t, []string{"ALL"}, hostConfig.CapAdd)
|
|
||||||
assert.Equal(t, []string{"seccomp=unconfined"}, hostConfig.SecurityOpt)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||||
@@ -915,6 +813,31 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
|||||||
assert.Equal(t, "abc123", info.ID)
|
assert.Equal(t, "abc123", info.ID)
|
||||||
assert.Equal(t, HealthNone, info.Health)
|
assert.Equal(t, HealthNone, info.Health)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("reports the mount sources by container path", func(t *testing.T) {
|
||||||
|
info := containerInfoFromInspect(container.InspectResponse{
|
||||||
|
Mounts: []container.MountPoint{
|
||||||
|
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"},
|
||||||
|
{Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"},
|
||||||
|
{Type: mount.TypeVolume, Name: "cache", Source: "/var/lib/docker/volumes/cache/_data", Destination: "/cache"},
|
||||||
|
{Type: mount.TypeBind, Source: "/custom/resolv.conf", Destination: "/etc/resolv.conf"},
|
||||||
|
},
|
||||||
|
HostConfig: &container.HostConfig{
|
||||||
|
Tmpfs: map[string]string{"/workspace/owner/repo//tmp/": "size=1m"},
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "cache", Target: "/cache/", VolumeOptions: &mount.VolumeOptions{Subpath: "project"}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
|
||||||
|
"/workspace/owner/repo/tmp": "",
|
||||||
|
"/var/run/docker.sock": "/var/run/docker.sock",
|
||||||
|
"/cache": "/var/lib/docker/volumes/cache/_data/project",
|
||||||
|
"/etc/resolv.conf": "/custom/resolv.conf",
|
||||||
|
"/etc/hosts": "",
|
||||||
|
"/etc/hostname": "",
|
||||||
|
}, info.Mounts)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||||
@@ -922,8 +845,10 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
|||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
input: &NewContainerInput{
|
input: &NewContainerInput{
|
||||||
NetworkMode: "bridge",
|
NetworkMode: "bridge",
|
||||||
Options: "--volume /host/tools:/opt/hostedtoolcache",
|
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
|
||||||
|
WorkflowOptions: "--volume /unapproved:/var/run/docker.sock",
|
||||||
|
ValidVolumes: []string{"/host/tools"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,6 +861,29 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
|||||||
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", ""))
|
||||||
|
assert.Equal(t, 1, warnings("", "--privileged"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
|
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"redis", "redis-primary"}, cr.input.NetworkAliases)
|
||||||
|
}
|
||||||
|
|
||||||
// A dead daemon must fail the job, not panic through logrus and not silently
|
// A dead daemon must fail the job, not panic through logrus and not silently
|
||||||
// drop the requested platform.
|
// drop the requested platform.
|
||||||
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
var CommonSocketLocations = []string{
|
|
||||||
"/var/run/docker.sock",
|
|
||||||
"/run/podman/podman.sock",
|
|
||||||
"$HOME/.colima/docker.sock",
|
|
||||||
"$XDG_RUNTIME_DIR/docker.sock",
|
|
||||||
"$XDG_RUNTIME_DIR/podman/podman.sock",
|
|
||||||
`\\.\pipe\docker_engine`,
|
|
||||||
"$HOME/.docker/run/docker.sock",
|
|
||||||
}
|
|
||||||
|
|
||||||
// returns socket URI or false if not found any
|
|
||||||
func socketLocation() (string, bool) {
|
|
||||||
if dockerHost, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
|
||||||
return dockerHost, true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range CommonSocketLocations {
|
|
||||||
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
|
|
||||||
if strings.HasPrefix(p, `\\.\`) {
|
|
||||||
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
|
||||||
}
|
|
||||||
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// This function, `isDockerHostURI`, takes a string argument `daemonPath`. It checks if the
|
|
||||||
// `daemonPath` is a valid Docker host URI. It does this by checking if the scheme of the URI (the
|
|
||||||
// part before "://") contains only alphabetic characters. If it does, the function returns true,
|
|
||||||
// indicating that the `daemonPath` is a Docker host URI. If it doesn't, or if the "://" delimiter
|
|
||||||
// is not found in the `daemonPath`, the function returns false.
|
|
||||||
func isDockerHostURI(daemonPath string) bool {
|
|
||||||
if before, _, ok := strings.Cut(daemonPath, "://"); ok {
|
|
||||||
scheme := before
|
|
||||||
if strings.IndexFunc(scheme, func(r rune) bool {
|
|
||||||
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z')
|
|
||||||
}) == -1 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type SocketAndHost struct {
|
|
||||||
Socket string
|
|
||||||
Host string
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSocketAndHost(containerSocket string) (SocketAndHost, error) {
|
|
||||||
log.Debugf("Handling container host and socket")
|
|
||||||
|
|
||||||
// Prefer DOCKER_HOST, don't override it
|
|
||||||
dockerHost, hasDockerHost := socketLocation()
|
|
||||||
socketHost := SocketAndHost{Socket: containerSocket, Host: dockerHost}
|
|
||||||
|
|
||||||
// ** socketHost.Socket cases **
|
|
||||||
// Case 1: User does _not_ want to mount a daemon socket (passes a dash)
|
|
||||||
// Case 2: User passes a filepath to the socket; is that even valid?
|
|
||||||
// Case 3: User passes a valid socket; do nothing
|
|
||||||
// Case 4: User omitted the flag; set a sane default
|
|
||||||
|
|
||||||
// ** DOCKER_HOST cases **
|
|
||||||
// Case A: DOCKER_HOST is set; use it, i.e. do nothing
|
|
||||||
// Case B: DOCKER_HOST is empty; use sane defaults
|
|
||||||
|
|
||||||
// Set host for sanity's sake, when the socket isn't useful
|
|
||||||
if !hasDockerHost && (socketHost.Socket == "-" || !isDockerHostURI(socketHost.Socket) || socketHost.Socket == "") {
|
|
||||||
// Cases: 1B, 2B, 4B
|
|
||||||
socket, found := socketLocation()
|
|
||||||
socketHost.Host = socket
|
|
||||||
hasDockerHost = found
|
|
||||||
}
|
|
||||||
|
|
||||||
// A - (dash) in socketHost.Socket means don't mount, preserve this value
|
|
||||||
// otherwise if socketHost.Socket is a filepath don't use it as socket
|
|
||||||
// Exit early if we're in an invalid state (e.g. when no DOCKER_HOST and user supplied "-", a dash or omitted)
|
|
||||||
if !hasDockerHost && socketHost.Socket != "" && !isDockerHostURI(socketHost.Socket) {
|
|
||||||
// Cases: 1B, 2B
|
|
||||||
// Should we early-exit here, since there is no host nor socket to talk to?
|
|
||||||
return SocketAndHost{}, fmt.Errorf("DOCKER_HOST was not set, couldn't be found in the usual locations, and the container daemon socket ('%s') is invalid", socketHost.Socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to DOCKER_HOST if set
|
|
||||||
if socketHost.Socket == "" && hasDockerHost {
|
|
||||||
// Cases: 4A
|
|
||||||
log.Debugf("Defaulting container socket to DOCKER_HOST")
|
|
||||||
socketHost.Socket = socketHost.Host
|
|
||||||
}
|
|
||||||
// Set sane default socket location if user omitted it
|
|
||||||
if socketHost.Socket == "" {
|
|
||||||
// Cases: 4B
|
|
||||||
socket, _ := socketLocation()
|
|
||||||
// socket is empty if it isn't found, so assignment here is at worst a no-op
|
|
||||||
log.Debugf("Defaulting container socket to default '%s'", socket)
|
|
||||||
socketHost.Socket = socket
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exit if both the DOCKER_HOST and socket are fulfilled
|
|
||||||
if hasDockerHost {
|
|
||||||
// Cases: 1A, 2A, 3A, 4A
|
|
||||||
if !isDockerHostURI(socketHost.Socket) {
|
|
||||||
// Cases: 1A, 2A
|
|
||||||
log.Debugf("DOCKER_HOST is set, but socket is invalid '%s'", socketHost.Socket)
|
|
||||||
}
|
|
||||||
return socketHost, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a sane DOCKER_HOST default if we can
|
|
||||||
if isDockerHostURI(socketHost.Socket) {
|
|
||||||
// Cases: 3B
|
|
||||||
log.Debugf("Setting DOCKER_HOST to container socket '%s'", socketHost.Socket)
|
|
||||||
socketHost.Host = socketHost.Socket
|
|
||||||
// Both DOCKER_HOST and container socket are valid; short-circuit exit
|
|
||||||
return socketHost, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Here there is no DOCKER_HOST _and_ the supplied container socket is not a valid URI (either invalid or a file path)
|
|
||||||
// Cases: 2B <- but is already handled at the top
|
|
||||||
// I.e. this path should never be taken
|
|
||||||
return SocketAndHost{}, fmt.Errorf("no DOCKER_HOST and an invalid container socket '%s'", socketHost.Socket)
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
var originalCommonSocketLocations = CommonSocketLocations
|
|
||||||
|
|
||||||
func isolateSocketEnv(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
|
|
||||||
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
|
|
||||||
t.Setenv("DOCKER_HOST", host)
|
|
||||||
} else {
|
|
||||||
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
|
||||||
socketURI := "/path/to/my.socket"
|
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost(socketURI)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostNoSocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost("")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
socketURI := "/path/to/my.socket"
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
defaultSocket, defaultSocketFound := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost(socketURI)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.True(t, defaultSocketFound, "Expected to find default socket")
|
|
||||||
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
|
|
||||||
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost("-")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
defaultSocket, found := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost("")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.True(t, found, "Expected a default socket to be found")
|
|
||||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catch
|
|
||||||
// > Your code breaks setting DOCKER_HOST if shouldMount is false.
|
|
||||||
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
|
||||||
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
|
||||||
mySocket := mySocketFile.Name()
|
|
||||||
unixSocket := "unix://" + mySocket
|
|
||||||
defer os.RemoveAll(mySocket)
|
|
||||||
assert.NoError(t, tmpErr) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
|
|
||||||
CommonSocketLocations = []string{mySocket}
|
|
||||||
defaultSocket, found := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost("")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
|
|
||||||
assert.True(t, found, "Expected default socket to be found")
|
|
||||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
mySocket := "/my/socket/path.sock"
|
|
||||||
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
|
||||||
defaultSocket, found := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost(mySocket)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.False(t, found, "Expected no default socket to be found")
|
|
||||||
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
|
|
||||||
assert.Error(t, err, "Expected an error in invalid state")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
socketURI := "unix:///path/to/my.socket"
|
|
||||||
CommonSocketLocations = []string{"/unusual", "/location"}
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
defaultSocket, found := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost(socketURI)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
// Default socket locations
|
|
||||||
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.False(t, found, "Expected no default socket to be found")
|
|
||||||
// Sane default
|
|
||||||
assert.NoError(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,10 @@ import (
|
|||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func IsolatedCacheContainer(context.Context, netip.Addr, string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
// requested name, tag and architecture exists in the local docker image store
|
// requested name, tag and architecture exists in the local docker image store
|
||||||
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||||
@@ -29,6 +34,18 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
|||||||
return false, errors.New("Unsupported Operation")
|
return false, errors.New("Unsupported Operation")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RemoveDockerJobResources(_ context.Context, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerProxy(_ context.Context, _ string) *DockerProxy {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||||
|
return nil, errors.New("Unsupported Operation")
|
||||||
|
}
|
||||||
|
|
||||||
// NewDockerBuildExecutor function to create a run executor for the container
|
// NewDockerBuildExecutor function to create a run executor for the container
|
||||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -74,6 +91,30 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
func NewDockerNetworkConnectExecutor(_, _ string) common.Executor {
|
||||||
|
return func(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkDisconnectExecutor(_, _ string) common.Executor {
|
||||||
|
return func(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanNetworks(_ context.Context, _, _ string, _ time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanitizeBinds(_ context.Context, _, binds []string) []string {
|
||||||
|
return binds
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -8,40 +8,67 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||||
return func(ctx context.Context) error {
|
cli, err := GetDockerClient(ctx)
|
||||||
cli, err := GetDockerClient(ctx)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
|
|
||||||
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, vol := range list.Items {
|
|
||||||
if vol.Name == volumeName {
|
|
||||||
return removeExecutor(volumeName, force)(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume not found - do nothing
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
for _, volumeName := range volumeNames {
|
||||||
|
if _, err := cli.VolumeCreate(ctx, client.VolumeCreateOptions{
|
||||||
|
Name: volumeName,
|
||||||
|
Labels: runnerLabels(runnerUUID),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeExecutor(volume string, force bool) common.Executor {
|
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{
|
||||||
|
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID).Add("dangling", "true"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for _, item := range volumes.Items {
|
||||||
|
created, err := time.Parse(time.RFC3339, item.CreatedAt)
|
||||||
|
// an unreadable or recent timestamp may belong to a job that is still starting up
|
||||||
|
if err != nil || created.After(createdBefore) || item.Labels[runnerUUIDLabel] != runnerUUID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := cli.VolumeRemove(ctx, item.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", item.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
common.Logger(ctx).Infof("removed docker volume %s left behind by an earlier job", item.Name)
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
common.Logger(ctx).Debugf("docker volume rm %s", volumeName)
|
||||||
logger.Debugf("docker volume rm %s", volume)
|
|
||||||
|
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -53,7 +80,10 @@ func removeExecutor(volume string, force bool) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
_, err = cli.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
|
||||||
|
if cerrdefs.IsNotFound(err) { // already gone is the outcome we wanted
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ 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"
|
||||||
@@ -71,12 +72,6 @@ func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) ConnectToNetwork(name string) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *HostEnvironment) Close() common.Executor {
|
func (e *HostEnvironment) Close() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
@@ -97,34 +92,7 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
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 {
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
srcPrefix := filepath.Dir(srcPath)
|
srcPrefix := filepath.Dir(srcPath)
|
||||||
@@ -142,10 +110,10 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
|
|||||||
ignorer = gitignore.NewMatcher(ps)
|
ignorer = gitignore.NewMatcher(ps)
|
||||||
}
|
}
|
||||||
fc := &filecollector.FileCollector{
|
fc := &filecollector.FileCollector{
|
||||||
Fs: &filecollector.DefaultFs{},
|
Ignorer: ignorer,
|
||||||
Ignorer: ignorer,
|
SrcPath: srcPath,
|
||||||
SrcPath: srcPath,
|
SrcPrefix: srcPrefix,
|
||||||
SrcPrefix: srcPrefix,
|
SkipGitDir: skipGitDir,
|
||||||
Handler: &filecollector.CopyCollector{
|
Handler: &filecollector.CopyCollector{
|
||||||
DstDir: destPath,
|
DstDir: destPath,
|
||||||
},
|
},
|
||||||
@@ -180,7 +148,6 @@ 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,
|
||||||
@@ -246,24 +213,8 @@ 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, &localEnv{env: env})
|
f, err := lookpath.LookPath2(cmd, 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 {
|
||||||
@@ -275,7 +226,7 @@ func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||||
ppty, tty, err := openPty()
|
ppty, tty, err := pty.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -401,8 +352,7 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
}
|
}
|
||||||
err = cmd.Wait()
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var exitErr *exec.ExitError
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
if errors.As(err, &exitErr) {
|
|
||||||
return ExitCodeError(exitErr.ExitCode())
|
return ExitCodeError(exitErr.ExitCode())
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -680,8 +630,11 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string {
|
|||||||
func goArchToActionArch(arch string) string {
|
func goArchToActionArch(arch string) string {
|
||||||
archMapper := map[string]string{
|
archMapper := map[string]string{
|
||||||
"x86_64": "X64",
|
"x86_64": "X64",
|
||||||
|
"amd64": "X64",
|
||||||
"386": "X86",
|
"386": "X86",
|
||||||
|
"arm": "ARM",
|
||||||
"aarch64": "ARM64",
|
"aarch64": "ARM64",
|
||||||
|
"arm64": "ARM64",
|
||||||
}
|
}
|
||||||
if arch, ok := archMapper[arch]; ok {
|
if arch, ok := archMapper[arch]; ok {
|
||||||
return arch
|
return arch
|
||||||
@@ -691,7 +644,9 @@ func goArchToActionArch(arch string) string {
|
|||||||
|
|
||||||
func goOsToActionOs(os string) string {
|
func goOsToActionOs(os string) string {
|
||||||
osMapper := map[string]string{
|
osMapper := map[string]string{
|
||||||
"darwin": "macOS",
|
"linux": "Linux",
|
||||||
|
"darwin": "macOS",
|
||||||
|
"windows": "Windows",
|
||||||
}
|
}
|
||||||
if os, ok := osMapper[os]; ok {
|
if os, ok := osMapper[os]; ok {
|
||||||
return os
|
return os
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import (
|
|||||||
// Type assert HostEnvironment implements ExecutionsEnvironment
|
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||||
var _ ExecutionsEnvironment = &HostEnvironment{}
|
var _ ExecutionsEnvironment = &HostEnvironment{}
|
||||||
|
|
||||||
|
func TestActionPlatformNames(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Linux", "macOS", "Windows"}, []string{goOsToActionOs("linux"), goOsToActionOs("darwin"), goOsToActionOs("windows")})
|
||||||
|
assert.Equal(t, []string{"X86", "X64", "ARM", "ARM64"}, []string{goArchToActionArch("386"), goArchToActionArch("amd64"), goArchToActionArch("arm"), goArchToActionArch("arm64")})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCopyDir(t *testing.T) {
|
func TestCopyDir(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -42,10 +47,32 @@ func TestCopyDir(t *testing.T) {
|
|||||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||||
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
|
err := e.CopyDir(e.Workdir, e.Path, true, false)(ctx)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyDirSkipGitDir(t *testing.T) {
|
||||||
|
for name, skipGitDir := range map[string]bool{"kept for a workspace copy": false, "skipped for an action copy": true} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
src := filepath.Join(t.TempDir(), "action")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
|
||||||
|
|
||||||
|
dest := filepath.Join(t.TempDir(), "dest")
|
||||||
|
e := &HostEnvironment{StdOut: os.Stdout}
|
||||||
|
require.NoError(t, e.CopyDir(dest, src+string(filepath.Separator), false, skipGitDir)(context.Background()))
|
||||||
|
|
||||||
|
assert.FileExists(t, filepath.Join(dest, "action.yml"))
|
||||||
|
if skipGitDir {
|
||||||
|
assert.NoDirExists(t, filepath.Join(dest, ".git"))
|
||||||
|
} else {
|
||||||
|
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetContainerArchive(t *testing.T) {
|
func TestGetContainerArchive(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -46,10 +46,11 @@ 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, "<<")
|
||||||
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
|
switch {
|
||||||
|
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
|
||||||
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
||||||
} else if multiLineEnv != -1 {
|
case multiLineEnv != -1:
|
||||||
multiLineEnvContent := ""
|
var multiLineEnvContent []string
|
||||||
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
||||||
delimiterFound := false
|
delimiterFound := false
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
@@ -58,10 +59,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
delimiterFound = true
|
delimiterFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if multiLineEnvContent != "" {
|
multiLineEnvContent = append(multiLineEnvContent, content)
|
||||||
multiLineEnvContent += "\n"
|
|
||||||
}
|
|
||||||
multiLineEnvContent += content
|
|
||||||
}
|
}
|
||||||
if err := s.Err(); err != nil {
|
if err := s.Err(); err != nil {
|
||||||
return fmt.Errorf("reading env file: %w", err)
|
return fmt.Errorf("reading env file: %w", err)
|
||||||
@@ -69,8 +67,8 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
if !delimiterFound {
|
if !delimiterFound {
|
||||||
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
||||||
}
|
}
|
||||||
localEnv[line[:multiLineEnv]] = multiLineEnvContent
|
localEnv[line[:multiLineEnv]] = strings.Join(multiLineEnvContent, "\n")
|
||||||
} else {
|
default:
|
||||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
|
|||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n\nline2\nEOF\n"), 0o600))
|
||||||
|
env = map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "\nline2", env["FOO"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
//go:build (!windows && !plan9 && !openbsd) || (!windows && !plan9 && !mips64)
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/creack/pty"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openPty() (*os.File, *os.File, error) {
|
|
||||||
return pty.Open()
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openPty() (*os.File, *os.File, error) {
|
|
||||||
return nil, nil, errors.New("Unsupported")
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openPty() (*os.File, *os.File, error) {
|
|
||||||
return nil, nil, errors.New("Unsupported")
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openPty() (*os.File, *os.File, error) {
|
|
||||||
return nil, nil, errors.New("Unsupported")
|
|
||||||
}
|
|
||||||
@@ -94,58 +94,29 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FileCollector struct {
|
type FileCollector struct {
|
||||||
Ignorer gitignore.Matcher
|
Ignorer gitignore.Matcher
|
||||||
SrcPath string
|
SrcPath string
|
||||||
SrcPrefix string
|
SrcPrefix string
|
||||||
Fs Fs
|
SkipGitDir bool
|
||||||
Handler Handler
|
Handler Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
type Fs interface {
|
func openGitIndex(path string) (*index.Index, error) {
|
||||||
Walk(root string, fn filepath.WalkFunc) error
|
repo, err := git.PlainOpen(path)
|
||||||
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
|
||||||
}
|
}
|
||||||
i, err := r.Storer.Index()
|
return repo.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, _ := fc.Fs.OpenGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
|
i, _ := openGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
|
||||||
return func(file string, fi os.FileInfo, err error) error {
|
return func(file string, fi os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ctx != nil {
|
if ctx != nil && ctx.Err() != nil {
|
||||||
select {
|
return errors.New("copy cancelled")
|
||||||
case <-ctx.Done():
|
|
||||||
return errors.New("copy cancelled")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sansPrefix := strings.TrimPrefix(file, fc.SrcPrefix)
|
sansPrefix := strings.TrimPrefix(file, fc.SrcPrefix)
|
||||||
@@ -154,6 +125,12 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
|||||||
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
|
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if fc.SkipGitDir && split[len(split)-1] == ".git" {
|
||||||
|
if fi.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var entry *index.Entry
|
var entry *index.Entry
|
||||||
if i != nil {
|
if i != nil {
|
||||||
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
|
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
|
||||||
@@ -175,7 +152,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 = fc.Fs.Walk(file, fc.CollectFiles(ctx, split))
|
err = filepath.Walk(file, fc.CollectFiles(ctx, split))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -185,7 +162,7 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
|||||||
|
|
||||||
// return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update)
|
// 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 := fc.Fs.Readlink(file)
|
linkName, err := os.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)
|
||||||
}
|
}
|
||||||
@@ -195,23 +172,15 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// open file
|
// open file
|
||||||
f, err := fc.Fs.Open(file)
|
f, err := os.Open(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
if ctx != nil {
|
if ctx != nil {
|
||||||
// make io.Copy cancellable by closing the file
|
stop := context.AfterFunc(ctx, func() { _ = f.Close() })
|
||||||
cpctx, cpfinish := context.WithCancel(ctx)
|
defer stop()
|
||||||
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,6 +6,7 @@ package filecollector
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,110 +14,41 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type memoryFs struct {
|
|
||||||
billy.Filesystem
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfs *memoryFs) walk(root string, fn filepath.WalkFunc) error {
|
|
||||||
dir, err := mfs.ReadDir(root)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for i := range dir {
|
|
||||||
filename := filepath.Join(root, dir[i].Name())
|
|
||||||
err = fn(filename, dir[i], nil)
|
|
||||||
if dir[i].IsDir() {
|
|
||||||
if err == filepath.SkipDir {
|
|
||||||
err = nil
|
|
||||||
} else if err := mfs.walk(filename, fn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfs *memoryFs) Walk(root string, fn filepath.WalkFunc) error {
|
|
||||||
stat, err := mfs.Lstat(root)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = fn(strings.Join([]string{root, "."}, string(filepath.Separator)), stat, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return mfs.walk(root, fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfs *memoryFs) OpenGitIndex(path string) (*index.Index, error) {
|
|
||||||
f, _ := mfs.Filesystem.Chroot(filepath.Join(path, ".git")) //nolint:staticcheck // pre-existing issue from nektos/act
|
|
||||||
storage := filesystem.NewStorage(f, cache.NewObjectLRUDefault())
|
|
||||||
i, err := storage.Index()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return i, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfs *memoryFs) Open(path string) (io.ReadCloser, error) {
|
|
||||||
return mfs.Filesystem.Open(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mfs *memoryFs) Readlink(path string) (string, error) {
|
|
||||||
return mfs.Filesystem.Readlink(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIgnoredTrackedfile(t *testing.T) {
|
func TestIgnoredTrackedfile(t *testing.T) {
|
||||||
fs := memfs.New()
|
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
|
||||||
_ = fs.MkdirAll("mygitrepo/.git", 0o777)
|
repo, err := git.PlainInit(repoDir, false)
|
||||||
dotgit, _ := fs.Chroot("mygitrepo/.git")
|
require.NoError(t, err)
|
||||||
worktree, _ := fs.Chroot("mygitrepo")
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".gitignore"), []byte(".*\n"), 0o644))
|
||||||
repo, _ := git.Init(filesystem.NewStorage(dotgit, cache.NewObjectLRUDefault()), worktree)
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".env"), []byte("test=val1\n"), 0o644))
|
||||||
f, _ := worktree.Create(".gitignore")
|
worktree, err := repo.Worktree()
|
||||||
_, _ = f.Write([]byte(".*\n"))
|
require.NoError(t, err)
|
||||||
f.Close()
|
_, err = worktree.Add(".gitignore")
|
||||||
// This file shouldn't be in the tar
|
require.NoError(t, err)
|
||||||
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")
|
var archive bytes.Buffer
|
||||||
tw := tar.NewWriter(tmpTar)
|
tw := tar.NewWriter(&archive)
|
||||||
ps, _ := gitignore.ReadPatterns(worktree, []string{})
|
patterns, err := gitignore.ReadPatterns(worktree.Filesystem, nil)
|
||||||
ignorer := gitignore.NewMatcher(ps)
|
require.NoError(t, err)
|
||||||
|
ignorer := gitignore.NewMatcher(patterns)
|
||||||
fc := &FileCollector{
|
fc := &FileCollector{
|
||||||
Fs: &memoryFs{Filesystem: fs},
|
|
||||||
Ignorer: ignorer,
|
Ignorer: ignorer,
|
||||||
SrcPath: "mygitrepo",
|
SrcPath: repoDir,
|
||||||
SrcPrefix: "mygitrepo" + string(filepath.Separator),
|
SrcPrefix: repoDir + string(filepath.Separator),
|
||||||
Handler: &TarCollector{
|
Handler: &TarCollector{
|
||||||
TarWriter: tw,
|
TarWriter: tw,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
err := fc.Fs.Walk("mygitrepo", fc.CollectFiles(context.Background(), []string{}))
|
err = filepath.Walk(repoDir, fc.CollectFiles(context.Background(), nil))
|
||||||
assert.NoError(t, err, "successfully collect files") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "successfully collect files")
|
||||||
tw.Close()
|
require.NoError(t, tw.Close())
|
||||||
_, _ = tmpTar.Seek(0, io.SeekStart)
|
tr := tar.NewReader(&archive)
|
||||||
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)
|
||||||
@@ -125,47 +57,32 @@ func TestIgnoredTrackedfile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSymlinks(t *testing.T) {
|
func TestSymlinks(t *testing.T) {
|
||||||
fs := memfs.New()
|
if runtime.GOOS == "windows" {
|
||||||
_ = fs.MkdirAll("mygitrepo/.git", 0o777)
|
t.Skip("creating symlinks requires elevated privileges on Windows")
|
||||||
dotgit, _ := fs.Chroot("mygitrepo/.git")
|
}
|
||||||
worktree, _ := fs.Chroot("mygitrepo")
|
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
|
||||||
repo, _ := git.Init(filesystem.NewStorage(dotgit, cache.NewObjectLRUDefault()), worktree)
|
repo, err := git.PlainInit(repoDir, false)
|
||||||
// This file shouldn't be in the tar
|
require.NoError(t, err)
|
||||||
f, err := worktree.Create(".env")
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".env"), []byte("test=val1\n"), 0o644))
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
require.NoError(t, os.Symlink(".env", filepath.Join(repoDir, "test.env")))
|
||||||
_, err = f.Write([]byte("test=val1\n"))
|
worktree, err := repo.Worktree()
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
require.NoError(t, err)
|
||||||
f.Close()
|
_, err = worktree.Add("test.env")
|
||||||
err = worktree.Symlink(".env", "test.env")
|
require.NoError(t, err)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
w, err := repo.Worktree()
|
var archive bytes.Buffer
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
tw := tar.NewWriter(&archive)
|
||||||
|
|
||||||
// .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{
|
||||||
Fs: &memoryFs{Filesystem: fs},
|
SrcPath: repoDir,
|
||||||
Ignorer: ignorer,
|
SrcPrefix: repoDir + string(filepath.Separator),
|
||||||
SrcPath: "mygitrepo",
|
|
||||||
SrcPrefix: "mygitrepo" + string(filepath.Separator),
|
|
||||||
Handler: &TarCollector{
|
Handler: &TarCollector{
|
||||||
TarWriter: tw,
|
TarWriter: tw,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
err = fc.Fs.Walk("mygitrepo", fc.CollectFiles(context.Background(), []string{}))
|
err = filepath.Walk(repoDir, fc.CollectFiles(context.Background(), nil))
|
||||||
assert.NoError(t, err, "successfully collect files") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "successfully collect files")
|
||||||
tw.Close()
|
require.NoError(t, tw.Close())
|
||||||
_, _ = tmpTar.Seek(0, io.SeekStart)
|
tr := tar.NewReader(&archive)
|
||||||
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 {
|
||||||
@@ -223,62 +140,14 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
|||||||
assert.Equal(t, "target", resolved)
|
assert.Equal(t, "target", resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultFsOpenReadlinkAndWalk(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("creating symlinks requires elevated privileges on Windows")
|
|
||||||
}
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink("file.txt", filepath.Join(root, "link.txt")))
|
|
||||||
|
|
||||||
fsys := &DefaultFs{}
|
|
||||||
var walked []string
|
|
||||||
require.NoError(t, fsys.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
||||||
require.NoError(t, err)
|
|
||||||
walked = append(walked, info.Name())
|
|
||||||
return nil
|
|
||||||
}))
|
|
||||||
require.Contains(t, walked, "file.txt")
|
|
||||||
require.Contains(t, walked, "link.txt")
|
|
||||||
|
|
||||||
file, err := fsys.Open(filepath.Join(root, "file.txt"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
data, err := io.ReadAll(file)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, file.Close())
|
|
||||||
require.Equal(t, "content", string(data))
|
|
||||||
|
|
||||||
link, err := fsys.Readlink(filepath.Join(root, "link.txt"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "file.txt", link)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
|
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
|
||||||
fc := &FileCollector{Fs: &memoryFs{Filesystem: memfs.New()}}
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
walk := fc.CollectFiles(cancelledContext(t), nil)
|
cancel()
|
||||||
|
walk := (&FileCollector{}).CollectFiles(ctx, nil)
|
||||||
|
|
||||||
err := walk("file", fakeFileInfo{name: "file"}, nil)
|
err := walk("file", nil, nil)
|
||||||
require.EqualError(t, err, "copy cancelled")
|
require.EqualError(t, err, "copy cancelled")
|
||||||
|
|
||||||
err = walk("file", fakeFileInfo{name: "file"}, os.ErrPermission)
|
err = walk("file", nil, os.ErrPermission)
|
||||||
require.ErrorIs(t, err, os.ErrPermission)
|
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 }
|
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -25,32 +26,29 @@ var (
|
|||||||
findGithubRepo = git.FindGithubRepo
|
findGithubRepo = git.FindGithubRepo
|
||||||
)
|
)
|
||||||
|
|
||||||
func withDefaultBranch(ctx context.Context, b string, event map[string]any) map[string]any {
|
type pinnedKey struct{}
|
||||||
repoI, ok := event["repository"]
|
|
||||||
|
// WithPinnedCheckout keeps a run's sha and ref fixed when HEAD moves.
|
||||||
|
func WithPinnedCheckout(ctx context.Context) context.Context {
|
||||||
|
if ctx.Value(pinnedKey{}) != nil {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, pinnedKey{}, &sync.Map{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func pinned(ctx context.Context, key string, lookup func() (string, error)) (string, error) {
|
||||||
|
cache, ok := ctx.Value(pinnedKey{}).(*sync.Map)
|
||||||
if !ok {
|
if !ok {
|
||||||
repoI = make(map[string]any)
|
return lookup()
|
||||||
}
|
}
|
||||||
|
cached, _ := cache.LoadOrStore(key, sync.OnceValues(lookup))
|
||||||
repo, ok := repoI.(map[string]any)
|
lookupOnce, _ := cached.(func() (string, error))
|
||||||
if !ok {
|
return lookupOnce()
|
||||||
common.Logger(ctx).Warnf("unable to set default branch to %v", b)
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the branch is already there return with no changes
|
|
||||||
if _, ok = repo["default_branch"]; ok {
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
repo["default_branch"] = b
|
|
||||||
event["repository"] = repo
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRef resolves the ref of the context from its event payload, falling back
|
// SetRef resolves the ref of the context from its event payload, falling back
|
||||||
// to the ref checked out in repoPath.
|
// to the ref checked out in repoPath.
|
||||||
func SetRef(ctx context.Context, ghc *model.GithubContext, defaultBranch, repoPath string) {
|
func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
// https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows
|
||||||
@@ -74,7 +72,7 @@ func SetRef(ctx context.Context, ghc *model.GithubContext, defaultBranch, repoPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ghc.Ref == "" {
|
if ghc.Ref == "" {
|
||||||
ref, err := findGitRef(ctx, repoPath)
|
ref, err := pinned(ctx, "ref:"+repoPath, func() (string, error) { return findGitRef(ctx, repoPath) })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("unable to get git ref: %v", err)
|
logger.Warningf("unable to get git ref: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -82,11 +80,15 @@ func SetRef(ctx context.Context, ghc *model.GithubContext, defaultBranch, repoPa
|
|||||||
ghc.Ref = ref
|
ghc.Ref = ref
|
||||||
}
|
}
|
||||||
|
|
||||||
// set the branch in the event data
|
repository, exists := ghc.Event["repository"]
|
||||||
if defaultBranch != "" {
|
if !exists {
|
||||||
ghc.Event = withDefaultBranch(ctx, defaultBranch, ghc.Event)
|
repository = map[string]any{}
|
||||||
} else {
|
}
|
||||||
ghc.Event = withDefaultBranch(ctx, "master", ghc.Event)
|
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 == "" {
|
if ghc.Ref == "" {
|
||||||
@@ -114,7 +116,10 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ghc.Sha == "" {
|
if ghc.Sha == "" {
|
||||||
_, sha, err := findGitRevision(ctx, repoPath)
|
sha, err := pinned(ctx, "sha:"+repoPath, func() (string, error) {
|
||||||
|
_, revision, err := findGitRevision(ctx, repoPath)
|
||||||
|
return revision, err
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("unable to get git revision: %v", err)
|
logger.Warningf("unable to get git revision: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -125,11 +130,11 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|||||||
|
|
||||||
// SetRepositoryAndOwner resolves the repository of the context from the git
|
// SetRepositoryAndOwner resolves the repository of the context from the git
|
||||||
// remote in repoPath when it is not set yet, and derives its owner.
|
// remote in repoPath when it is not set yet, and derives its owner.
|
||||||
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, githubInstance, remoteName, repoPath string) {
|
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, githubInstance, repoPath string) {
|
||||||
if ghc.Repository == "" {
|
if ghc.Repository == "" {
|
||||||
repo, err := findGithubRepo(ctx, repoPath, githubInstance, remoteName)
|
repo, err := findGithubRepo(ctx, repoPath, githubInstance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v; remoteName: %v, repoPath: %v): %v", githubInstance, remoteName, repoPath, err)
|
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v, repoPath: %v): %v", githubInstance, repoPath, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ghc.Repository = repo
|
ghc.Repository = repo
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func TestSetRef(t *testing.T) {
|
|||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRef(context.Background(), ghc, "main", "/some/dir")
|
SetRef(context.Background(), ghc, "/some/dir")
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|
||||||
assert.Equal(t, table.ref, ghc.Ref)
|
assert.Equal(t, table.ref, ghc.Ref)
|
||||||
@@ -122,7 +122,7 @@ func TestSetRef(t *testing.T) {
|
|||||||
Event: map[string]any{},
|
Event: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRef(context.Background(), ghc, "", "/some/dir")
|
SetRef(context.Background(), ghc, "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
||||||
})
|
})
|
||||||
@@ -214,4 +214,17 @@ func TestSetSha(t *testing.T) {
|
|||||||
assert.Equal(t, table.sha, ghc.Sha)
|
assert.Equal(t, table.sha, ghc.Sha)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("pinned checkout keeps the first sha and ref", func(t *testing.T) {
|
||||||
|
ctx := WithPinnedCheckout(context.Background())
|
||||||
|
for _, head := range []string{"first", "moved"} {
|
||||||
|
findGitRevision = func(context.Context, string) (string, string, error) { return "", head, nil }
|
||||||
|
findGitRef = func(context.Context, string) (string, error) { return "refs/heads/" + head, nil }
|
||||||
|
ghc := &model.GithubContext{Event: map[string]any{}}
|
||||||
|
SetSha(ctx, ghc, "/some/dir")
|
||||||
|
SetRef(ctx, ghc, "/some/dir")
|
||||||
|
assert.Equal(t, "first", ghc.Sha)
|
||||||
|
assert.Equal(t, "refs/heads/first", ghc.Ref)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
+14
-2
@@ -4,6 +4,18 @@
|
|||||||
|
|
||||||
package lookpath
|
package lookpath
|
||||||
|
|
||||||
type Env interface {
|
import (
|
||||||
Getenv(name string) string
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getenv(env map[string]string, name string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for key, value := range env {
|
||||||
|
if strings.EqualFold(name, key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env[name]
|
||||||
}
|
}
|
||||||
@@ -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, lenv Env) (string, error) {
|
func LookPath2(file string, _ map[string]string) (string, error) {
|
||||||
// Wasm can not execute processes, so act as if there are no executables at all.
|
// 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, lenv Env) (string, error) {
|
func LookPath2(file string, env map[string]string) (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, lenv Env) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
path := lenv.Getenv("path")
|
path := getenv(env, "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, lenv Env) (string, error) {
|
func LookPath2(file string, env map[string]string) (string, error) {
|
||||||
// NOTE(rsc): I wish we could use the Plan 9 behavior here
|
// 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, lenv Env) (string, error) {
|
|||||||
}
|
}
|
||||||
return "", &Error{file, err}
|
return "", &Error{file, err}
|
||||||
}
|
}
|
||||||
path := lenv.Getenv("PATH")
|
path := getenv(env, "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,12 +13,6 @@ 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")
|
||||||
@@ -26,7 +20,7 @@ func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := LookPath2("tool", testEnv{"PATH": string(filepath.ListSeparator) + dir})
|
got, err := LookPath2("tool", map[string]string{"PATH": string(filepath.ListSeparator) + dir})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -42,7 +36,7 @@ func TestLookPath2DirectPathDoesNotSearchPath(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := LookPath2(exe, testEnv{"PATH": ""})
|
got, err := LookPath2(exe, map[string]string{"PATH": ""})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -58,7 +52,7 @@ func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := LookPath2(file, testEnv{"PATH": dir})
|
_, err := LookPath2(file, map[string]string{"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)
|
||||||
@@ -67,7 +61,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", testEnv{"PATH": dir})
|
_, err = LookPath2("missing", map[string]string{"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, lenv Env) (string, error) {
|
func LookPath2(file string, env map[string]string) (string, error) {
|
||||||
var exts []string
|
var exts []string
|
||||||
x := lenv.Getenv(`PATHEXT`)
|
x := getenv(env, `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, lenv Env) (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 := lenv.Getenv("path")
|
path := getenv(env, "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
|
||||||
|
|||||||
+103
-137
@@ -13,6 +13,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -32,7 +33,7 @@ type actionStep interface {
|
|||||||
step
|
step
|
||||||
|
|
||||||
getActionModel() *model.Action
|
getActionModel() *model.Action
|
||||||
getCompositeRunContext(context.Context) *RunContext
|
getCompositeRunContext(context.Context) (*RunContext, error)
|
||||||
getCompositeSteps() *compositeSteps
|
getCompositeSteps() *compositeSteps
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,21 +125,9 @@ 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()
|
||||||
@@ -148,30 +137,25 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var containerActionDirCopy string
|
containerActionDirCopy := strings.TrimSuffix(containerActionDir, actionPath)
|
||||||
containerActionDirCopy = strings.TrimSuffix(containerActionDir, actionPath)
|
|
||||||
logger.Debug(containerActionDirCopy)
|
logger.Debug(containerActionDirCopy)
|
||||||
|
|
||||||
if !strings.HasSuffix(containerActionDirCopy, `/`) {
|
if !strings.HasSuffix(containerActionDirCopy, `/`) {
|
||||||
containerActionDirCopy += `/`
|
containerActionDirCopy += `/`
|
||||||
}
|
}
|
||||||
|
|
||||||
if rc.Config != nil && rc.Config.ActionCache != nil {
|
|
||||||
ta, err := cachedActionTar(ctx, step, stepModel.Uses, "")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer ta.Close()
|
|
||||||
return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer git.AcquireCloneLock(actionDir)()
|
defer git.AcquireCloneLock(actionDir)()
|
||||||
|
|
||||||
|
if !rc.Config.NoActionPatch {
|
||||||
|
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
|
||||||
|
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
|
||||||
|
}
|
||||||
|
|
||||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx)
|
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
||||||
@@ -186,11 +170,10 @@ 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)
|
|
||||||
|
|
||||||
err := setupActionEnv(ctx, step, remoteAction)
|
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
||||||
if err != nil {
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
|
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,12 +188,12 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)}
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Main))
|
||||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
|
|
||||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||||
case x.IsDocker():
|
case x.IsDocker():
|
||||||
location := actionLocation
|
location := actionLocation
|
||||||
if remoteAction == nil {
|
if remoteAction == nil {
|
||||||
@@ -235,11 +218,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.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
|
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir),
|
||||||
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
|
rc.JobContainer.Exec(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,
|
||||||
@@ -252,18 +235,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupActionEnv(ctx context.Context, step actionStep, _ *remoteAction) error {
|
// /var/run is a symlink, so without the flag node's import.meta.url differs from argv[1], which ESM actions compare.
|
||||||
rc := step.getRunContext()
|
func nodeActionCommand(script string) []string {
|
||||||
|
return []string{"node", "--preserve-symlinks-main", script}
|
||||||
// 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
|
||||||
@@ -359,12 +333,6 @@ 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,
|
||||||
@@ -386,34 +354,40 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
|
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
eval := rc.NewStepExpressionEvaluator(ctx, step)
|
cmd, err := shellquote.Split(step.getStepModel().With["args"])
|
||||||
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"]))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(cmd) == 0 {
|
ee, err := evalDockerEnv(ctx, step, action)
|
||||||
cmd = action.Runs.Args
|
|
||||||
evalDockerArgs(ctx, step, action, &cmd)
|
|
||||||
}
|
|
||||||
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint)
|
if action.Runs.Args != nil {
|
||||||
|
// a fresh slice, the manifest is evaluated again for every stage
|
||||||
|
cmd = make([]string, len(action.Runs.Args))
|
||||||
|
for i, v := range action.Runs.Args {
|
||||||
|
if cmd[i], err = ee.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate runs.args: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entrypoint, err := dockerEntrypoint(step, stage)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
prepImage,
|
prepImage,
|
||||||
stepContainer.Pull(forcePull),
|
stepContainer.Pull(forcePull),
|
||||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
stepContainer.Remove(),
|
||||||
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(step actionStep, stage stepStage) ([]string, error) {
|
||||||
runs := step.getActionModel().Runs
|
runs := step.getActionModel().Runs
|
||||||
|
|
||||||
var entrypoint string
|
var entrypoint string
|
||||||
@@ -423,10 +397,12 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvalu
|
|||||||
case stepStagePost:
|
case stepStagePost:
|
||||||
entrypoint = runs.PostEntrypoint
|
entrypoint = runs.PostEntrypoint
|
||||||
default:
|
default:
|
||||||
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
|
|
||||||
return fields, nil
|
|
||||||
}
|
|
||||||
entrypoint = runs.Entrypoint
|
entrypoint = runs.Entrypoint
|
||||||
|
if entrypoint == "" {
|
||||||
|
if fields := strings.Fields(step.getStepModel().With["entrypoint"]); len(fields) > 0 {
|
||||||
|
return fields, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if entrypoint == "" {
|
if entrypoint == "" {
|
||||||
@@ -435,47 +411,35 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvalu
|
|||||||
return shellquote.Split(entrypoint)
|
return shellquote.Split(entrypoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
|
// evalDockerEnv returns an evaluator bound to the environment it installed.
|
||||||
|
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
|
||||||
|
|
||||||
|
var err error
|
||||||
inputs := make(map[string]string)
|
inputs := make(map[string]string)
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
eval := rc.NewExpressionEvaluator(ctx)
|
||||||
// Set Defaults
|
// Set Defaults
|
||||||
for k, input := range action.Inputs {
|
for k, input := range action.Inputs {
|
||||||
inputs[k] = eval.Interpolate(ctx, input.Default)
|
if inputs[k], err = eval.Interpolate(ctx, input.Default); err != nil {
|
||||||
}
|
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
|
||||||
if stepModel.With != nil {
|
|
||||||
for k, v := range stepModel.With {
|
|
||||||
inputs[k] = eval.Interpolate(ctx, v)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mergeIntoMap(step, step.getEnv(), inputs)
|
mergeIntoMap(step, step.getEnv(), inputs, step.getStepModel().With)
|
||||||
|
|
||||||
stepEE := rc.NewStepExpressionEvaluator(ctx, step)
|
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
||||||
for i, v := range *cmd {
|
runsEnv := make(map[string]string, len(action.Runs.Env))
|
||||||
(*cmd)[i] = stepEE.Interpolate(ctx, v)
|
for k, v := range action.Runs.Env {
|
||||||
}
|
if runsEnv[k], err = ee.Interpolate(ctx, v); err != nil {
|
||||||
mergeIntoMap(step, step.getEnv(), action.Runs.Env)
|
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
|
}
|
||||||
ee := rc.NewStepExpressionEvaluator(ctx, step)
|
|
||||||
for k, v := range *step.getEnv() {
|
|
||||||
(*step.getEnv())[k] = ee.Interpolate(ctx, v)
|
|
||||||
}
|
}
|
||||||
|
mergeIntoMap(step, step.getEnv(), runsEnv, maps.Clone(*step.getEnv()))
|
||||||
|
return ee, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string) container.Container {
|
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
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
|
|
||||||
})
|
|
||||||
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))
|
||||||
@@ -483,32 +447,31 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
|
|
||||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts(ctx)
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
if rc.IsHostEnv(ctx) {
|
if rc.IsHostEnv() {
|
||||||
networkMode = "default"
|
networkMode = "default"
|
||||||
}
|
}
|
||||||
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
return 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-"+stepModel.ID),
|
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.getStepModel().ID),
|
||||||
Env: envList,
|
Env: envList,
|
||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
NetworkMode: networkMode,
|
NetworkMode: networkMode,
|
||||||
Binds: binds,
|
Binds: binds,
|
||||||
Stdout: logWriter,
|
Stdout: logWriter,
|
||||||
Stderr: logWriter,
|
Stderr: logWriter,
|
||||||
Privileged: rc.Config.Privileged,
|
Privileged: rc.Config.Privileged,
|
||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
Options: rc.Config.ContainerOptions,
|
RunnerOptions: runnerOptions,
|
||||||
AutoRemove: rc.Config.AutoRemove,
|
AutoRemove: true,
|
||||||
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) {
|
||||||
@@ -521,15 +484,19 @@ func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *Run
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) {
|
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error {
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
eval := rc.NewExpressionEvaluator(ctx)
|
||||||
for inputID, input := range action.Inputs {
|
for inputID, input := range action.Inputs {
|
||||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||||
envKey = "INPUT_" + envKey
|
envKey = "INPUT_" + envKey
|
||||||
if _, ok := (*env)[envKey]; !ok {
|
if _, ok := (*env)[envKey]; !ok {
|
||||||
(*env)[envKey] = eval.Interpolate(ctx, input.Default)
|
var err error
|
||||||
|
if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
||||||
@@ -629,31 +596,33 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
||||||
|
|
||||||
x := action.Runs.Using
|
x := action.Runs.Using
|
||||||
|
if !x.IsComposite() {
|
||||||
|
// defaults in pre steps were missing, however provided inputs are available
|
||||||
|
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case x.IsNode():
|
case x.IsNode():
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
|
||||||
|
|
||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Pre)}
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Pre))
|
||||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
|
|
||||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||||
|
|
||||||
case x.IsDocker():
|
case x.IsDocker():
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
|
||||||
|
|
||||||
return execDockerActionStage(ctx, step, stepStagePre)
|
return execDockerActionStage(ctx, step, stepStagePre)
|
||||||
|
|
||||||
case x.IsComposite():
|
case x.IsComposite():
|
||||||
if step.getCompositeSteps() == nil {
|
if step.getCompositeSteps() == nil {
|
||||||
step.getCompositeRunContext(ctx)
|
if _, err := step.getCompositeRunContext(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
|
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
|
||||||
@@ -662,9 +631,6 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
return errors.New("missing steps in composite action")
|
return errors.New("missing steps in composite action")
|
||||||
|
|
||||||
case x == model.ActionRunsUsingGo:
|
case x == model.ActionRunsUsingGo:
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
|
||||||
|
|
||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -676,8 +642,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.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
|
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir),
|
||||||
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
|
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""),
|
||||||
)(ctx)
|
)(ctx)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
@@ -739,12 +705,12 @@ func runPostStep(step actionStep) common.Executor {
|
|||||||
|
|
||||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
|
|
||||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Post)}
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Post))
|
||||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
|
|
||||||
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
|
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||||
|
|
||||||
case x.IsDocker():
|
case x.IsDocker():
|
||||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
@@ -770,8 +736,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.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
|
rc.JobContainer.Exec(buildArgs, *step.getEnv(), "", containerActionDir),
|
||||||
rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
|
rc.JobContainer.Exec(execArgs, *step.getEnv(), "", ""),
|
||||||
)(ctx)
|
)(ctx)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
git "github.com/go-git/go-git/v5"
|
|
||||||
config "github.com/go-git/go-git/v5/config"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/object"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ActionCache interface {
|
|
||||||
Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error)
|
|
||||||
GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type GoGitActionCache struct {
|
|
||||||
Path string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c GoGitActionCache) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
|
|
||||||
gitPath := path.Join(c.Path, safeFilename(cacheDir)+".git")
|
|
||||||
gogitrepo, err := git.PlainInit(gitPath, true)
|
|
||||||
if errors.Is(err, git.ErrRepositoryAlreadyExists) {
|
|
||||||
gogitrepo, err = git.PlainOpen(gitPath)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
tmpBranch := make([]byte, 12)
|
|
||||||
if _, err := rand.Read(tmpBranch); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
branchName := hex.EncodeToString(tmpBranch)
|
|
||||||
|
|
||||||
var auth transport.AuthMethod
|
|
||||||
if token != "" {
|
|
||||||
auth = &http.BasicAuth{
|
|
||||||
Username: "token",
|
|
||||||
Password: token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remote, err := gogitrepo.CreateRemoteAnonymous(&config.RemoteConfig{
|
|
||||||
Name: "anonymous",
|
|
||||||
URLs: []string{
|
|
||||||
url,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = gogitrepo.DeleteBranch(branchName)
|
|
||||||
}()
|
|
||||||
if err := remote.FetchContext(ctx, &git.FetchOptions{
|
|
||||||
RefSpecs: []config.RefSpec{
|
|
||||||
config.RefSpec(ref + ":" + branchName),
|
|
||||||
},
|
|
||||||
Auth: auth,
|
|
||||||
Force: true,
|
|
||||||
}); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
hash, err := gogitrepo.ResolveRevision(plumbing.Revision(branchName))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return hash.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c GoGitActionCache) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
|
|
||||||
gitPath := path.Join(c.Path, safeFilename(cacheDir)+".git")
|
|
||||||
gogitrepo, err := git.PlainOpen(gitPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
commit, err := gogitrepo.CommitObject(plumbing.NewHash(sha))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
files, err := commit.Files()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rpipe, wpipe := io.Pipe()
|
|
||||||
// Interrupt io.Copy using ctx
|
|
||||||
ch := make(chan int, 1)
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
wpipe.CloseWithError(ctx.Err())
|
|
||||||
case <-ch:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
defer wpipe.Close()
|
|
||||||
defer close(ch)
|
|
||||||
tw := tar.NewWriter(wpipe)
|
|
||||||
cleanIncludePrefix := path.Clean(includePrefix)
|
|
||||||
wpipe.CloseWithError(files.ForEach(func(f *object.File) error {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
name := f.Name
|
|
||||||
if strings.HasPrefix(name, cleanIncludePrefix+"/") {
|
|
||||||
name = name[len(cleanIncludePrefix)+1:]
|
|
||||||
} else if cleanIncludePrefix != "." && name != cleanIncludePrefix {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmode, err := f.Mode.ToOSFileMode()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if fmode&fs.ModeSymlink == fs.ModeSymlink {
|
|
||||||
content, err := f.Contents()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tw.WriteHeader(&tar.Header{
|
|
||||||
Name: name,
|
|
||||||
Mode: int64(fmode),
|
|
||||||
Linkname: content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
err = tw.WriteHeader(&tar.Header{
|
|
||||||
Name: name,
|
|
||||||
Mode: int64(fmode),
|
|
||||||
Size: f.Size,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reader, err := f.Reader()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = io.Copy(tw, reader)
|
|
||||||
return err
|
|
||||||
}))
|
|
||||||
}()
|
|
||||||
return rpipe, err
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/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", "[email protected]",
|
|
||||||
"GIT_COMMITTER_NAME=test", "[email protected]",
|
|
||||||
"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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -17,7 +18,7 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) (map[string]string, error) {
|
||||||
env := make(map[string]string)
|
env := make(map[string]string)
|
||||||
stepEnv := *step.getEnv()
|
stepEnv := *step.getEnv()
|
||||||
for k, v := range stepEnv {
|
for k, v := range stepEnv {
|
||||||
@@ -28,7 +29,7 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ee := parent.NewStepExpressionEvaluator(ctx, step)
|
ee := parent.NewActionInputsExpressionEvaluator(ctx, step)
|
||||||
|
|
||||||
for inputID, input := range step.getActionModel().Inputs {
|
for inputID, input := range step.getActionModel().Inputs {
|
||||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||||
@@ -36,55 +37,80 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
|
|||||||
|
|
||||||
// lookup if key is defined in the step but the already
|
// lookup if key is defined in the step but the already
|
||||||
// evaluated value from the environment
|
// evaluated value from the environment
|
||||||
_, defined := step.getStepModel().With[inputID]
|
defined := false
|
||||||
|
for key := range step.getStepModel().With {
|
||||||
|
if strings.EqualFold(key, inputID) {
|
||||||
|
defined = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if value, ok := stepEnv[envKey]; defined && ok {
|
if value, ok := stepEnv[envKey]; defined && ok {
|
||||||
env[envKey] = value
|
env[envKey] = value
|
||||||
} else {
|
} else {
|
||||||
// defaults could contain expressions
|
// defaults could contain expressions
|
||||||
env[envKey] = ee.Interpolate(ctx, input.Default)
|
var err error
|
||||||
|
if env[envKey], err = ee.Interpolate(ctx, input.Default); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gh := step.getGithubContext(ctx)
|
gh := step.getGithubContext(ctx)
|
||||||
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
|
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
|
||||||
env["GITHUB_ACTION_REF"] = gh.ActionRef
|
env["GITHUB_ACTION_REF"] = gh.ActionRef
|
||||||
|
|
||||||
return env
|
return env, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) *RunContext {
|
func (rc *RunContext) setCompositeActionEnv(env map[string]string) {
|
||||||
env := evaluateCompositeInputAndEnv(ctx, parent, step)
|
rc.setActionEnv(env)
|
||||||
|
for key := range rc.Env {
|
||||||
|
if strings.HasPrefix(key, "INPUT_") {
|
||||||
|
delete(rc.Env, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) (*RunContext, error) {
|
||||||
|
env, err := evaluateCompositeInputAndEnv(ctx, parent, step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// run with the global config but without secrets
|
// run with the global config but without secrets
|
||||||
configCopy := *(parent.Config)
|
configCopy := *parent.Config
|
||||||
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
|
||||||
compositerc := &RunContext{
|
compositerc := &RunContext{
|
||||||
Name: parent.Name,
|
Name: parent.Name,
|
||||||
JobName: parent.JobName,
|
JobName: parent.JobName,
|
||||||
|
Matrix: parent.Matrix,
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: parent.Run.JobID,
|
JobID: parent.Run.JobID,
|
||||||
Workflow: &model.Workflow{
|
Workflow: &model.Workflow{
|
||||||
Name: parent.Run.Workflow.Name,
|
Name: parent.Run.Workflow.Name,
|
||||||
Jobs: map[string]*model.Job{
|
Jobs: map[string]*model.Job{
|
||||||
parent.Run.JobID: {},
|
parent.Run.JobID: {Strategy: parent.Run.Job().Strategy},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Config: &configCopy,
|
Config: &configCopy,
|
||||||
StepResults: map[string]*model.StepResult{},
|
StepResults: map[string]*model.StepResult{},
|
||||||
JobContainer: parent.JobContainer,
|
JobContainer: parent.JobContainer,
|
||||||
ActionPath: actionPath,
|
ActionPath: actionPath,
|
||||||
Env: env,
|
GlobalEnv: parent.GlobalEnv,
|
||||||
GlobalEnv: parent.GlobalEnv,
|
Masks: parent.Masks,
|
||||||
Masks: parent.Masks,
|
ExtraPath: parent.ExtraPath,
|
||||||
ExtraPath: parent.ExtraPath,
|
Parent: parent,
|
||||||
Parent: parent,
|
EventJSON: parent.EventJSON,
|
||||||
EventJSON: parent.EventJSON,
|
platformImage: parent.platformImage,
|
||||||
|
jobIndex: parent.jobIndex,
|
||||||
|
jobTotal: parent.jobTotal,
|
||||||
}
|
}
|
||||||
|
compositerc.setCompositeActionEnv(env)
|
||||||
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
return compositerc
|
return compositerc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
||||||
@@ -105,7 +131,10 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
compositeRC := step.getCompositeRunContext(ctx)
|
compositeRC, err := step.getCompositeRunContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
steps := step.getCompositeSteps()
|
steps := step.getCompositeSteps()
|
||||||
|
|
||||||
@@ -115,14 +144,17 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
|
|
||||||
ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
|
ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
|
||||||
|
|
||||||
err := steps.main(ctx)
|
err = steps.main(ctx)
|
||||||
|
|
||||||
// Map outputs from composite RunContext to job RunContext
|
// Map outputs from composite RunContext to job RunContext
|
||||||
eval := compositeRC.NewExpressionEvaluator(ctx)
|
eval := compositeRC.NewExpressionEvaluator(ctx)
|
||||||
for outputName, output := range action.Outputs {
|
for outputName, output := range action.Outputs {
|
||||||
rc.setOutput(ctx, map[string]string{
|
value, outputErr := eval.Interpolate(ctx, output.Value)
|
||||||
"name": outputName,
|
if outputErr != nil {
|
||||||
}, eval.Interpolate(ctx, output.Value))
|
err = errors.Join(err, fmt.Errorf("unable to interpolate output %s: %w", outputName, outputErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc.setOutput(ctx, map[string]string{"name": outputName}, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
||||||
@@ -131,7 +163,7 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
// repeated composite actions grow rc.Masks exponentially.
|
// repeated composite actions grow rc.Masks exponentially.
|
||||||
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
||||||
rc.ExtraPath = compositeRC.ExtraPath
|
rc.ExtraPath = compositeRC.ExtraPath
|
||||||
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
|
// Propagate GlobalEnv only, so composite inputs and step-local values do not escape.
|
||||||
mergeIntoMap := mergeIntoMapCaseSensitive
|
mergeIntoMap := mergeIntoMapCaseSensitive
|
||||||
if rc.JobContainer.IsEnvironmentCaseInsensitive() {
|
if rc.JobContainer.IsEnvironmentCaseInsensitive() {
|
||||||
mergeIntoMap = mergeIntoMapCaseInsensitive
|
mergeIntoMap = mergeIntoMapCaseInsensitive
|
||||||
@@ -181,20 +213,7 @@ 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, func(ctx context.Context) error {
|
steps = append(steps, newCompositeStepLogExecutor(rc.newCompositeCommandExecutor(step.main()), stepID))
|
||||||
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 {
|
||||||
@@ -207,6 +226,7 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
steps = append(steps, common.JobError)
|
steps = append(steps, common.JobError)
|
||||||
|
preSteps = append(preSteps, common.JobError)
|
||||||
return &compositeSteps{
|
return &compositeSteps{
|
||||||
pre: func(ctx context.Context) error {
|
pre: func(ctx context.Context) error {
|
||||||
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
|
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
|
||||||
@@ -222,19 +242,7 @@ 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)
|
||||||
|
|
||||||
// We need to inject a composite RunContext related command
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
// 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)
|
||||||
|
|||||||
@@ -6,9 +6,61 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCompositeActionParity(t *testing.T) {
|
||||||
|
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
strategy := &model.Strategy{MaxParallelString: "3", RawMatrix: yaml.Node{Kind: yaml.MappingNode}}
|
||||||
|
parent := &RunContext{
|
||||||
|
Config: &Config{},
|
||||||
|
Matrix: map[string]any{"os": "linux"},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
|
||||||
|
JobContainer: &jobContainerMock{},
|
||||||
|
platformImage: "-self-hosted",
|
||||||
|
jobIndex: 1,
|
||||||
|
jobTotal: 4,
|
||||||
|
}
|
||||||
|
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
|
||||||
|
Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
|
||||||
|
RunContext: parent,
|
||||||
|
action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}},
|
||||||
|
env: map[string]string{"INPUT_SHARED": "outer"},
|
||||||
|
}, "/action")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Same(t, strategy, composite.Run.Job().Strategy)
|
||||||
|
assert.True(t, composite.IsHostEnv())
|
||||||
|
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
|
||||||
|
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ strategy.job-index }}|${{ inputs.shared }}")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "linux|3|1|outer", interpolated)
|
||||||
|
assert.NotContains(t, composite.Env, "INPUT_SHARED")
|
||||||
|
|
||||||
|
nestedEnv := composite.GetEnv()
|
||||||
|
require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite))
|
||||||
|
assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("propagates pre failures", func(t *testing.T) {
|
||||||
|
setCloneExecutor(t, func(git.NewGitCloneExecutorInput) common.Executor { return common.NewErrorExecutor(assert.AnError) })
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir()},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||||
|
JobContainer: &jobContainerMock{},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.ErrorIs(t, rc.compositeExecutor(&model.Action{Runs: model.ActionRuns{Using: "composite", Steps: []model.Step{{ID: "nested", Uses: "org/action@v1"}}}}).pre(t.Context()), assert.AnError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppendUniqueMasks(t *testing.T) {
|
func TestAppendUniqueMasks(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -68,3 +120,51 @@ func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, []string{"secret"}, parentMasks)
|
assert.Equal(t, []string{"secret"}, parentMasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompositeStepIfReadsJobStatusExceptInItsOwnMainSteps(t *testing.T) {
|
||||||
|
failStep := func(rc *RunContext) {
|
||||||
|
rc.StepResults["failed"] = &model.StepResult{Conclusion: model.StepStatusFailure}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(job, composite *RunContext)
|
||||||
|
nested bool
|
||||||
|
stage stepStage
|
||||||
|
want map[string]bool
|
||||||
|
}{
|
||||||
|
{"post-if reads the failed job", func(job, _ *RunContext) { failStep(job) }, false, stepStagePost, map[string]bool{"success()": false, "failure()": true, "job.status == 'failure'": true}},
|
||||||
|
{"main if ignores the failed job", func(job, _ *RunContext) { failStep(job) }, false, stepStageMain, map[string]bool{"success()": true, "failure()": false}},
|
||||||
|
{"main if reads the composite's own failure", func(_, composite *RunContext) { failStep(composite) }, false, stepStageMain, map[string]bool{"success()": false, "failure()": true}},
|
||||||
|
{"nested main if ignores the outer composite's failure", func(_, composite *RunContext) { failStep(composite) }, true, stepStageMain, map[string]bool{"success()": true}},
|
||||||
|
{"main if sees the cancelled job", func(job, _ *RunContext) { job.markCancelled() }, false, stepStageMain, map[string]bool{"cancelled()": true, "success()": false}},
|
||||||
|
{"post-if sees the cancelled job", func(job, _ *RunContext) { job.markCancelled() }, false, stepStagePost, map[string]bool{"cancelled()": true, "success()": false}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
job := makeTestRC(t, "runs-on: ubuntu-latest")
|
||||||
|
composite := newTestCompositeRunContext(t, job)
|
||||||
|
rc := composite
|
||||||
|
if tt.nested {
|
||||||
|
rc = newTestCompositeRunContext(t, composite)
|
||||||
|
}
|
||||||
|
tt.setup(job, composite)
|
||||||
|
step := &stepRun{RunContext: rc}
|
||||||
|
for expr, want := range tt.want {
|
||||||
|
enabled, err := isStepEnabled(t.Context(), expr, step, tt.stage)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, enabled, expr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestCompositeRunContext(t *testing.T, parent *RunContext) *RunContext {
|
||||||
|
composite, err := newCompositeRunContext(t.Context(), parent, &stepActionRemote{
|
||||||
|
Step: &model.Step{},
|
||||||
|
RunContext: parent,
|
||||||
|
action: &model.Action{},
|
||||||
|
env: map[string]string{},
|
||||||
|
}, "/action")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return composite
|
||||||
|
}
|
||||||
+104
-102
@@ -8,6 +8,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -23,12 +27,10 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type closerMock struct {
|
type closerFunc func()
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *closerMock) Close() error {
|
func (close closerFunc) Close() error {
|
||||||
m.Called()
|
close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,15 @@ 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
|
||||||
@@ -52,30 +63,14 @@ runs:
|
|||||||
step: &model.Step{},
|
step: &model.Step{},
|
||||||
filename: "action.yml",
|
filename: "action.yml",
|
||||||
fileContent: yaml,
|
fileContent: yaml,
|
||||||
expected: &model.Action{
|
expected: yamlAction,
|
||||||
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: &model.Action{
|
expected: yamlAction,
|
||||||
Name: "name",
|
|
||||||
Runs: model.ActionRuns{
|
|
||||||
Using: "node16",
|
|
||||||
Main: "main.js",
|
|
||||||
PreIf: "always()",
|
|
||||||
PostIf: "always()",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "readDockerfile",
|
name: "readDockerfile",
|
||||||
@@ -121,14 +116,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) {
|
||||||
closerMock := &closerMock{}
|
closed := false
|
||||||
|
|
||||||
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), closerMock, nil
|
return strings.NewReader(tt.fileContent), closerFunc(func() { closed = true }), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFile := func(filename string, data []byte, perm fs.FileMode) error {
|
writeFile := func(filename string, data []byte, perm fs.FileMode) error {
|
||||||
@@ -137,58 +132,16 @@ 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)
|
||||||
|
|
||||||
closerMock.AssertExpectations(t)
|
assert.Equal(t, tt.filename != "", closed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -274,7 +227,7 @@ func TestActionRunner(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil })
|
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false, true).Return(func(ctx context.Context) error { return nil })
|
||||||
|
|
||||||
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
|
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
|
||||||
for k, v := range tt.expectedEnv {
|
for k, v := range tt.expectedEnv {
|
||||||
@@ -285,11 +238,11 @@ func TestActionRunner(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
|
cm.On("Exec", []string{"node", "--preserve-symlinks-main", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
|
||||||
|
|
||||||
tt.step.getRunContext().JobContainer = cm
|
tt.step.getRunContext().JobContainer = cm
|
||||||
|
|
||||||
err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx)
|
err := runActionImpl(tt.step, "dir", mustNewRemoteAction(t, "org/repo/path@ref"))(ctx)
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
@@ -297,6 +250,32 @@ func TestActionRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNodeActionCommandPreservesSymlinkedEntrypoint(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("symlink creation requires privileges on Windows")
|
||||||
|
}
|
||||||
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
|
actionDir := t.TempDir()
|
||||||
|
entrypoint := filepath.Join(actionDir, "index.mjs")
|
||||||
|
require.NoError(t, os.WriteFile(entrypoint, []byte(`
|
||||||
|
import {fileURLToPath} from "node:url";
|
||||||
|
const self = fileURLToPath(import.meta.url);
|
||||||
|
if (process.argv[1] !== self) {
|
||||||
|
console.log("argv[1]:", process.argv[1], "import.meta.url:", self);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
symlinkedActionDir := filepath.Join(t.TempDir(), "action")
|
||||||
|
require.NoError(t, os.Symlink(actionDir, symlinkedActionDir))
|
||||||
|
symlinkedEntrypoint := filepath.Join(symlinkedActionDir, "index.mjs")
|
||||||
|
|
||||||
|
args := nodeActionCommand(symlinkedEntrypoint)
|
||||||
|
output, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput()
|
||||||
|
require.NoError(t, err, "%s", output)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
|
|
||||||
@@ -337,11 +316,12 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +337,7 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
|||||||
copyEntered := make(chan struct{})
|
copyEntered := make(chan struct{})
|
||||||
|
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error {
|
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false, true).Return(func(ctx context.Context) error {
|
||||||
close(copyEntered)
|
close(copyEntered)
|
||||||
<-releaseCopy
|
<-releaseCopy
|
||||||
return nil
|
return nil
|
||||||
@@ -481,18 +461,11 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
|||||||
case <-innerEntered:
|
case <-innerEntered:
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
t.Fatalf("execAsDocker returned without entering inner: %v", err)
|
t.Fatalf("execAsDocker returned without entering inner: %v", err)
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("inner build executor not entered after lock released")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
releaseOnce()
|
releaseOnce()
|
||||||
|
<-done
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerActionImageTag(t *testing.T) {
|
func TestDockerActionImageTag(t *testing.T) {
|
||||||
@@ -542,8 +515,6 @@ func TestDockerActionImageTag(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
|
|
||||||
// and runs.env, and the `entrypoint` input applies to the main stage alone.
|
|
||||||
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
||||||
orig := ContainerNewContainer
|
orig := ContainerNewContainer
|
||||||
defer func() { ContainerNewContainer = orig }()
|
defer func() { ContainerNewContainer = orig }()
|
||||||
@@ -551,25 +522,62 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
stage stepStage
|
stage stepStage
|
||||||
|
with map[string]string
|
||||||
|
runs model.ActionRuns
|
||||||
|
env map[string]string
|
||||||
|
wantCmd []string
|
||||||
wantEntrypoint []string
|
wantEntrypoint []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "main stage prefers the entrypoint input",
|
name: "main stage prefers manifest values",
|
||||||
stage: stepStageMain,
|
stage: stepStageMain,
|
||||||
wantEntrypoint: []string{"input.sh"},
|
with: map[string]string{"args": "caller", "entrypoint": "input.sh"},
|
||||||
|
runs: model.ActionRuns{
|
||||||
|
Entrypoint: "main.sh",
|
||||||
|
Args: []string{"manifest"},
|
||||||
|
Env: map[string]string{"ACTION_ONLY": "manifest"},
|
||||||
|
},
|
||||||
|
wantCmd: []string{"manifest"},
|
||||||
|
wantEntrypoint: []string{"main.sh"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pre stage uses runs.pre-entrypoint",
|
name: "main stage uses caller fallbacks",
|
||||||
stage: stepStagePre,
|
stage: stepStageMain,
|
||||||
|
with: map[string]string{"args": "caller --flag", "entrypoint": "input.sh --verbose"},
|
||||||
|
runs: model.ActionRuns{Env: map[string]string{"ACTION_ONLY": "manifest"}},
|
||||||
|
wantCmd: []string{"caller", "--flag"},
|
||||||
|
wantEntrypoint: []string{"input.sh", "--verbose"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit empty manifest args suppress caller args",
|
||||||
|
stage: stepStageMain,
|
||||||
|
with: map[string]string{"args": "caller"},
|
||||||
|
runs: model.ActionRuns{Args: []string{}},
|
||||||
|
wantCmd: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pre stage keeps step environment",
|
||||||
|
stage: stepStagePre,
|
||||||
|
with: map[string]string{"entrypoint": "input.sh"},
|
||||||
|
runs: model.ActionRuns{
|
||||||
|
PreEntrypoint: "pre.sh --verbose",
|
||||||
|
Args: []string{"hello"},
|
||||||
|
Env: map[string]string{"SHARED": "manifest"},
|
||||||
|
},
|
||||||
|
env: map[string]string{"SHARED": "step"},
|
||||||
|
wantCmd: []string{"hello"},
|
||||||
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "post stage uses runs.post-entrypoint",
|
name: "post stage uses manifest entrypoint",
|
||||||
stage: stepStagePost,
|
stage: stepStagePost,
|
||||||
|
runs: model.ActionRuns{PostEntrypoint: "post.sh", Args: []string{"hello"}},
|
||||||
|
wantCmd: []string{"hello"},
|
||||||
wantEntrypoint: []string{"post.sh"},
|
wantEntrypoint: []string{"post.sh"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tc.runs.Using, tc.runs.Image = "docker", "docker://node:14"
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
var input *container.NewContainerInput
|
var input *container.NewContainerInput
|
||||||
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
@@ -578,22 +586,14 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
step := &stepActionRemote{
|
step := &stepActionRemote{
|
||||||
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}},
|
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: tc.with},
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
},
|
},
|
||||||
action: &model.Action{Runs: model.ActionRuns{
|
action: &model.Action{Runs: tc.runs},
|
||||||
Using: "docker",
|
env: mergeMaps(tc.env),
|
||||||
Image: "docker://node:14",
|
|
||||||
PreEntrypoint: "pre.sh --verbose",
|
|
||||||
Entrypoint: "main.sh",
|
|
||||||
PostEntrypoint: "post.sh",
|
|
||||||
Args: []string{"hello"},
|
|
||||||
Env: map[string]string{"MY_VAR": "world"},
|
|
||||||
}},
|
|
||||||
env: map[string]string{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||||
@@ -604,9 +604,11 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
||||||
require.NotNil(t, input)
|
require.NotNil(t, input)
|
||||||
|
assert.Equal(t, tc.wantCmd, input.Cmd)
|
||||||
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
||||||
assert.Equal(t, []string{"hello"}, input.Cmd)
|
for key, value := range mergeMaps(tc.runs.Env, tc.env) {
|
||||||
assert.Contains(t, input.Env, "MY_VAR=world")
|
assert.Contains(t, input.Env, key+"="+value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,6 +62,8 @@ func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
|
|||||||
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
||||||
|
setJobResult(context.Background(), rc, rc, true)
|
||||||
|
assert.Equal(t, "cancelled", rc.Run.Job().Result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
||||||
@@ -107,17 +110,14 @@ func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
|
|||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
// A short deadline that we let elapse between steps, so no step records the error itself.
|
ctx := newControllableDeadlineContext(context.Background())
|
||||||
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(c context.Context) error {
|
func(context.Context) error {
|
||||||
ran = append(ran, "step1")
|
ran = append(ran, "step1")
|
||||||
// Block until the job deadline elapses, then return cleanly: the interrupt lands in the loop's between-steps check, not inside a step.
|
ctx.expire()
|
||||||
<-c.Done()
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
func(c context.Context) error {
|
func(c context.Context) error {
|
||||||
@@ -250,7 +250,8 @@ func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
|
|||||||
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
|
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
|
||||||
// from cancellation (so the steps run) yet still carry a usable error container.
|
// from cancellation (so the steps run) yet still carry a usable error container.
|
||||||
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
||||||
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
jobSpan := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID{1}, SpanID: trace.SpanID{1}})
|
||||||
|
cancelled, cancel := context.WithCancel(trace.ContextWithSpanContext(common.WithJobErrorContainer(context.Background()), jobSpan))
|
||||||
cancel()
|
cancel()
|
||||||
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
||||||
|
|
||||||
@@ -259,6 +260,7 @@ func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
|||||||
|
|
||||||
// Detached from cancellation, so the post steps actually run.
|
// Detached from cancellation, so the post steps actually run.
|
||||||
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
|
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
|
||||||
|
assert.Equal(t, jobSpan, trace.SpanContextFromContext(postCtx), "post steps must stay in the job's trace")
|
||||||
|
|
||||||
// A failing post step records its error instead of panicking.
|
// A failing post step records its error instead of panicking.
|
||||||
require.NotPanics(t, func() {
|
require.NotPanics(t, func() {
|
||||||
|
|||||||
+43
-16
@@ -18,11 +18,17 @@ var commandPatternGA *regexp.Regexp
|
|||||||
var commandPatternADO *regexp.Regexp
|
var commandPatternADO *regexp.Regexp
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
commandPatternGA = regexp.MustCompile("^::([^ ]+)( (.+))?::([^\r\n]*)[\r\n]+$")
|
commandPatternGA = regexp.MustCompile("^::([^ ]+?)( (.+?))?::([^\r\n]*)[\r\n]*$")
|
||||||
commandPatternADO = regexp.MustCompile("^##\\[([^ ]+)( (.+))?]([^\r\n]*)[\r\n]+$")
|
// excluding ']' ends the command info at the first bracket, as GitHub does
|
||||||
|
commandPatternADO = regexp.MustCompile("^##\\[([^ \\]]+)( ([^\\]]*))?]([^\r\n]*)[\r\n]*$")
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
|
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
|
||||||
|
command, kvPairs, arg, _, ok = tryParseActionCommand(line)
|
||||||
|
return command, kvPairs, arg, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryParseActionCommand(line string) (command string, kvPairs map[string]string, arg string, legacy, ok bool) {
|
||||||
if m := commandPatternGA.FindStringSubmatch(line); m != nil {
|
if m := commandPatternGA.FindStringSubmatch(line); m != nil {
|
||||||
command = m[1]
|
command = m[1]
|
||||||
kvPairs = parseKeyValuePairs(m[3], ",")
|
kvPairs = parseKeyValuePairs(m[3], ",")
|
||||||
@@ -32,19 +38,21 @@ func tryParseRawActionCommand(line string) (command string, kvPairs map[string]s
|
|||||||
command = m[1]
|
command = m[1]
|
||||||
kvPairs = parseKeyValuePairs(m[3], ";")
|
kvPairs = parseKeyValuePairs(m[3], ";")
|
||||||
arg = m[4]
|
arg = m[4]
|
||||||
|
legacy = true
|
||||||
ok = true
|
ok = true
|
||||||
}
|
}
|
||||||
return command, kvPairs, arg, ok
|
return command, kvPairs, arg, legacy, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
resumeCommand := ""
|
resumeCommand := ""
|
||||||
return func(line string) bool {
|
return func(line string) bool {
|
||||||
command, kvPairs, arg, ok := tryParseRawActionCommand(line)
|
command, kvPairs, arg, legacy, ok := tryParseActionCommand(line)
|
||||||
if !ok {
|
if !ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
command = strings.ToLower(command)
|
||||||
|
|
||||||
if resumeCommand != "" {
|
if resumeCommand != "" {
|
||||||
// There should not be any emojis in the log output for Gitea.
|
// There should not be any emojis in the log output for Gitea.
|
||||||
@@ -54,19 +62,24 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
// Resumed here rather than from the switch, because the end token is arbitrary
|
// Resumed here rather than from the switch, because the end token is arbitrary
|
||||||
// and a token naming a real command would otherwise never resume.
|
// and a token naming a real command would otherwise never resume.
|
||||||
if command == resumeCommand {
|
if strings.EqualFold(command, resumeCommand) {
|
||||||
resumeCommand = ""
|
resumeCommand = ""
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
arg = UnescapeCommandData(arg)
|
if legacy {
|
||||||
kvPairs = unescapeKvPairs(kvPairs)
|
arg = UnescapeLegacyCommand(arg)
|
||||||
|
kvPairs = unescapeKvPairs(kvPairs, UnescapeLegacyCommand)
|
||||||
|
} else {
|
||||||
|
arg = UnescapeCommandData(arg)
|
||||||
|
kvPairs = unescapeKvPairs(kvPairs, unescapeCommandProperty)
|
||||||
|
}
|
||||||
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
switch command {
|
switch command {
|
||||||
case "set-env":
|
case "set-env":
|
||||||
rc.setEnv(ctx, kvPairs, arg)
|
rc.setEnv(ctx, kvPairs, arg, true)
|
||||||
case "set-output":
|
case "set-output":
|
||||||
rc.setOutput(ctx, kvPairs, arg)
|
rc.setOutput(ctx, kvPairs, arg)
|
||||||
case "add-path":
|
case "add-path":
|
||||||
@@ -139,8 +152,16 @@ func (rc *RunContext) takeUnsecureCommandError() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) {
|
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string, fromCommand bool) {
|
||||||
name := kvPairs["name"]
|
name := kvPairs["name"]
|
||||||
|
if strings.EqualFold(name, "NODE_OPTIONS") {
|
||||||
|
message := "Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command."
|
||||||
|
if fromCommand {
|
||||||
|
message = "Can't update NODE_OPTIONS environment variable using ::set-env:: command."
|
||||||
|
}
|
||||||
|
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||||
if rc.Env == nil {
|
if rc.Env == nil {
|
||||||
rc.Env = make(map[string]string)
|
rc.Env = make(map[string]string)
|
||||||
@@ -159,14 +180,14 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
|
|||||||
mergeIntoMap(rc.GlobalEnv, newenv)
|
mergeIntoMap(rc.GlobalEnv, newenv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) setEnvFile(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||||
|
rc.setEnv(ctx, kvPairs, arg, false)
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
|
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
stepID := rc.CurrentStep
|
stepID := rc.CurrentStep
|
||||||
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 {
|
||||||
@@ -193,7 +214,7 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
|
|||||||
rtn := make(map[string]string)
|
rtn := make(map[string]string)
|
||||||
kvPairList := strings.SplitSeq(kvPairs, separator)
|
kvPairList := strings.SplitSeq(kvPairs, separator)
|
||||||
for kvPair := range kvPairList {
|
for kvPair := range kvPairList {
|
||||||
kv := strings.Split(kvPair, "=")
|
kv := strings.SplitN(kvPair, "=", 2)
|
||||||
if len(kv) == 2 {
|
if len(kv) == 2 {
|
||||||
rtn[kv[0]] = kv[1]
|
rtn[kv[0]] = kv[1]
|
||||||
}
|
}
|
||||||
@@ -206,6 +227,7 @@ var (
|
|||||||
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
|
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
|
||||||
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
|
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
|
||||||
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
||||||
|
legacyCommandUnescaper = strings.NewReplacer("%3B", ";", "%0D", "\r", "%0A", "\n", "%5D", "]", "%25", "%")
|
||||||
)
|
)
|
||||||
|
|
||||||
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
||||||
@@ -222,9 +244,14 @@ func unescapeCommandProperty(arg string) string {
|
|||||||
return commandPropertyUnescaper.Replace(arg)
|
return commandPropertyUnescaper.Replace(arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unescapeKvPairs(kvPairs map[string]string) map[string]string {
|
// UnescapeLegacyCommand decodes a "##[cmd]" line, which also spells ";" and "]" escaped.
|
||||||
|
func UnescapeLegacyCommand(arg string) string {
|
||||||
|
return legacyCommandUnescaper.Replace(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeKvPairs(kvPairs map[string]string, unescape func(string) string) map[string]string {
|
||||||
for k, v := range kvPairs {
|
for k, v := range kvPairs {
|
||||||
kvPairs[k] = unescapeCommandProperty(v)
|
kvPairs[k] = unescape(v)
|
||||||
}
|
}
|
||||||
return kvPairs
|
return kvPairs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,21 @@ func unsecureRC() *RunContext {
|
|||||||
|
|
||||||
func TestSetEnv(t *testing.T) {
|
func TestSetEnv(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
logger, hook := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
rc := unsecureRC()
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
|
handler("::SET-ENV name=NODE_OPTIONS::--require command.js\n")
|
||||||
|
rc.setEnvFile(ctx, map[string]string{"name": "node_options"}, "--require env.js")
|
||||||
|
a.NotContains(rc.Env, "NODE_OPTIONS")
|
||||||
|
a.NotContains(rc.Env, "node_options")
|
||||||
|
entries := hook.AllEntries()
|
||||||
|
require.Len(t, entries, 3)
|
||||||
|
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[1].Message)
|
||||||
|
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[2].Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||||
@@ -85,6 +94,16 @@ func TestSetOutput(t *testing.T) {
|
|||||||
|
|
||||||
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
|
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
|
||||||
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
|
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
|
||||||
|
handler("::set-output name=symbol::std::vector")
|
||||||
|
a.Equal("std::vector", rc.StepResults["my-step"].Outputs["symbol"])
|
||||||
|
handler("::set-output name=a=b::value\n")
|
||||||
|
a.Equal("value", rc.StepResults["my-step"].Outputs["a=b"])
|
||||||
|
handler("##[set-output name=legacy%3B%5D]value%3B%5D")
|
||||||
|
a.Equal("value;]", rc.StepResults["my-step"].Outputs["legacy;]"])
|
||||||
|
handler("##[set-output name=bracket]value]tail")
|
||||||
|
a.Equal("value]tail", rc.StepResults["my-step"].Outputs["bracket"])
|
||||||
|
handler("::set-output name=modern%3B%5D::value%3B%5D\n")
|
||||||
|
a.Equal("value%3B%5D", rc.StepResults["my-step"].Outputs["modern%3B%5D"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddpath(t *testing.T) {
|
func TestAddpath(t *testing.T) {
|
||||||
@@ -110,7 +129,7 @@ func TestStopCommands(t *testing.T) {
|
|||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
handler("::stop-commands::my-end-token\n")
|
handler("::stop-commands::MY-END-TOKEN\n")
|
||||||
handler("::set-env name=x::abcd\n")
|
handler("::set-env name=x::abcd\n")
|
||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
handler("::my-end-token::\n")
|
handler("::my-end-token::\n")
|
||||||
@@ -163,10 +182,10 @@ func TestAddmask(t *testing.T) {
|
|||||||
|
|
||||||
rc := new(RunContext)
|
rc := new(RunContext)
|
||||||
handler := rc.commandHandler(loggerCtx)
|
handler := rc.commandHandler(loggerCtx)
|
||||||
handler("::add-mask::my-secret-value\n")
|
handler("::ADD-MASK::my::secret")
|
||||||
|
|
||||||
a.Equal("***", hook.LastEntry().Message)
|
a.Equal("***", hook.LastEntry().Message)
|
||||||
a.NotEqual("*my-secret-value", hook.LastEntry().Message)
|
a.Equal([]string{"my::secret"}, rc.Masks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// based on https://stackoverflow.com/a/10476304
|
// based on https://stackoverflow.com/a/10476304
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ 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
|
||||||
@@ -50,18 +52,13 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
args := cm.Called(destPath, srcPath, useGitIgnore)
|
args := cm.Called(destPath, srcPath, useGitIgnore, skipGitDir)
|
||||||
return args.Get(0).(func(context.Context) error)
|
return args.Get(0).(func(context.Context) error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/client"
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerProxyMountedJob(t *testing.T) {
|
||||||
|
mode := os.Getenv("ACT_TEST_DOCKER_PROXY")
|
||||||
|
if mode == "" {
|
||||||
|
t.Skip("set ACT_TEST_DOCKER_PROXY=proxy or direct to verify mounted Docker access")
|
||||||
|
}
|
||||||
|
require.Contains(t, []string{"proxy", "direct"}, mode)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
dockerClient, err := container.GetDockerClient(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { assert.NoError(t, dockerClient.Close()) })
|
||||||
|
require.True(t, strings.HasPrefix(dockerClient.DaemonHost(), "unix://"), "mounted Docker coverage requires a Unix daemon socket")
|
||||||
|
|
||||||
|
fixtureDir, err := filepath.Abs("testdata/docker-proxy")
|
||||||
|
require.NoError(t, err)
|
||||||
|
resourceName := "gitea-proxy-test-" + strings.ToLower(rand.Text())
|
||||||
|
runner, err := New(&Config{
|
||||||
|
Workdir: fixtureDir,
|
||||||
|
ActionCacheDir: t.TempDir(),
|
||||||
|
EventName: "push",
|
||||||
|
PlatformPicker: mapPlatformPicker(platforms),
|
||||||
|
ContainerNamePrefix: resourceName,
|
||||||
|
ContainerDaemonSocket: dockerClient.DaemonHost(),
|
||||||
|
ContainerMaxLifetime: 2 * time.Minute,
|
||||||
|
ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro",
|
||||||
|
ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"},
|
||||||
|
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
plan, err := planner.PlanEvent("push")
|
||||||
|
if mode == "direct" {
|
||||||
|
plan, err = planner.PlanJob("proxy")
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
jobName := runContext.jobContainerName()
|
||||||
|
jobNetwork, _ := runContext.networkNameForGitea()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cleanCtx, cleanCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||||
|
defer cleanCancel()
|
||||||
|
if _, err := dockerClient.ContainerRemove(cleanCtx, jobName, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); !cerrdefs.IsNotFound(err) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{resourceName, jobNetwork} {
|
||||||
|
if _, err := dockerClient.NetworkRemove(cleanCtx, name, client.NetworkRemoveOptions{}); !cerrdefs.IsNotFound(err) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{resourceName, jobName, jobName + "-env"} {
|
||||||
|
if _, err := dockerClient.VolumeRemove(cleanCtx, name, client.VolumeRemoveOptions{}); !cerrdefs.IsNotFound(err) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hook := &test.Hook{}
|
||||||
|
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHook(ctx, hook)))
|
||||||
|
var messages []string
|
||||||
|
for _, entry := range hook.AllEntries() {
|
||||||
|
messages = append(messages, strings.TrimSpace(entry.Message))
|
||||||
|
}
|
||||||
|
require.Contains(t, messages, "docker proxy post verified")
|
||||||
|
if mode == "proxy" {
|
||||||
|
require.Contains(t, messages, "docker binds verified")
|
||||||
|
}
|
||||||
|
_, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{})
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err)
|
||||||
|
_, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{})
|
||||||
|
if mode == "proxy" {
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err), "labelled network survived cleanup: %v", err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
_, err = dockerClient.VolumeInspect(ctx, resourceName, client.VolumeInspectOptions{})
|
||||||
|
if mode == "proxy" {
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err), "labelled volume survived cleanup: %v", err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-154
@@ -26,41 +26,18 @@ import (
|
|||||||
"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
|
||||||
using := make(map[string]exprparser.Needs)
|
|
||||||
strategy := make(map[string]any)
|
strategy := make(map[string]any)
|
||||||
if rc.Run != nil {
|
if rc.Run != nil {
|
||||||
job := rc.Run.Job()
|
strategy = exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal)
|
||||||
if job != nil && job.Strategy != nil {
|
|
||||||
strategy["fail-fast"] = job.Strategy.FailFast
|
|
||||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs := rc.Run.Workflow.Jobs
|
|
||||||
jobNeeds := rc.Run.Job().Needs()
|
|
||||||
|
|
||||||
for _, needs := range jobNeeds {
|
|
||||||
using[needs] = exprparser.Needs{
|
|
||||||
Outputs: jobs[needs].Outputs,
|
|
||||||
Result: jobs[needs].NeedsResult(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// only setup jobs context in case of workflow_call
|
// only setup jobs context in case of workflow_call
|
||||||
// and existing expression evaluator (this means, jobs are at
|
// and existing expression evaluator (this means, jobs are at
|
||||||
@@ -68,7 +45,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
if rc.caller != nil && rc.ExprEval != nil {
|
if rc.caller != nil && rc.ExprEval != nil {
|
||||||
workflowCallResult = map[string]*model.WorkflowCallResult{}
|
workflowCallResult = map[string]*model.WorkflowCallResult{}
|
||||||
|
|
||||||
for jobName, job := range jobs {
|
for jobName, job := range rc.Run.Workflow.Jobs {
|
||||||
result := model.WorkflowCallResult{
|
result := model.WorkflowCallResult{
|
||||||
Outputs: map[string]string{},
|
Outputs: map[string]string{},
|
||||||
}
|
}
|
||||||
@@ -79,7 +56,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
}
|
}
|
||||||
|
|
||||||
ghc := rc.getGithubContext(ctx)
|
ghc := rc.getGithubContext(ctx)
|
||||||
inputs := getEvaluatorInputs(ctx, rc, nil, ghc)
|
inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc)
|
||||||
|
|
||||||
ee := &exprparser.EvaluationEnvironment{
|
ee := &exprparser.EvaluationEnvironment{
|
||||||
Github: ghc,
|
Github: ghc,
|
||||||
@@ -89,16 +66,16 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
// todo: should be unavailable
|
// todo: should be unavailable
|
||||||
// but required to interpolate/evaluate the step outputs on the job
|
// but required to interpolate/evaluate the step outputs on the job
|
||||||
Steps: rc.getStepsContext(),
|
Steps: rc.getStepsContext(),
|
||||||
Secrets: getWorkflowSecrets(ctx, rc),
|
Secrets: getWorkflowSecrets(rc),
|
||||||
Vars: getWorkflowVars(ctx, rc),
|
Vars: getWorkflowVars(ctx, rc),
|
||||||
Strategy: strategy,
|
Strategy: strategy,
|
||||||
Matrix: rc.Matrix,
|
Matrix: rc.Matrix,
|
||||||
Needs: using,
|
Needs: exprparser.NeedsContext(rc.Run),
|
||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
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,
|
||||||
@@ -110,47 +87,35 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
//go:embed hashfiles/index.js
|
//go:embed hashfiles/index.js
|
||||||
var hashfiles string
|
var hashfiles string
|
||||||
|
|
||||||
// NewStepExpressionEvaluator creates a new evaluator
|
// NewStepExpressionEvaluator creates a new evaluator with the `inputs` of the enclosing workflow or composite action
|
||||||
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) ExpressionEvaluator {
|
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
||||||
|
return rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, rc.getJobContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewActionInputsExpressionEvaluator creates a new evaluator with the step's own with: values as `inputs`
|
||||||
|
func (rc *RunContext) NewActionInputsExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
||||||
|
return rc.newStepExpressionEvaluator(ctx, step, inputsFromEnv(*step.getEnv()), rc.getJobContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any, jobContext *model.JobContext) *ExpressionEvaluator {
|
||||||
// todo: cleanup EvaluationEnvironment creation
|
// todo: cleanup EvaluationEnvironment creation
|
||||||
job := rc.Run.Job()
|
|
||||||
strategy := make(map[string]any)
|
|
||||||
if job.Strategy != nil {
|
|
||||||
strategy["fail-fast"] = job.Strategy.FailFast
|
|
||||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs := rc.Run.Workflow.Jobs
|
|
||||||
jobNeeds := rc.Run.Job().Needs()
|
|
||||||
|
|
||||||
using := make(map[string]exprparser.Needs)
|
|
||||||
for _, needs := range jobNeeds {
|
|
||||||
using[needs] = exprparser.Needs{
|
|
||||||
Outputs: jobs[needs].Outputs,
|
|
||||||
Result: jobs[needs].NeedsResult(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ghc := rc.getGithubContext(ctx)
|
|
||||||
inputs := getEvaluatorInputs(ctx, rc, step, ghc)
|
|
||||||
|
|
||||||
ee := &exprparser.EvaluationEnvironment{
|
ee := &exprparser.EvaluationEnvironment{
|
||||||
Github: step.getGithubContext(ctx),
|
Github: step.getGithubContext(ctx),
|
||||||
Env: *step.getEnv(),
|
Env: *step.getEnv(),
|
||||||
Job: rc.getJobContext(),
|
Job: jobContext,
|
||||||
Steps: rc.getStepsContext(),
|
Steps: rc.getStepsContext(),
|
||||||
Secrets: getWorkflowSecrets(ctx, rc),
|
Secrets: getWorkflowSecrets(rc),
|
||||||
Vars: getWorkflowVars(ctx, rc),
|
Vars: getWorkflowVars(ctx, rc),
|
||||||
Strategy: strategy,
|
Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal),
|
||||||
Matrix: rc.Matrix,
|
Matrix: rc.Matrix,
|
||||||
Needs: using,
|
Needs: exprparser.NeedsContext(rc.Run),
|
||||||
// todo: should be unavailable
|
// todo: should be unavailable
|
||||||
// but required to interpolate/evaluate the inputs in actions/composite
|
// but required to interpolate/evaluate the inputs in actions/composite
|
||||||
Inputs: inputs,
|
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
|
||||||
HashFiles: getHashFilesFunction(ctx, rc),
|
HashFiles: getHashFilesFunction(ctx, rc),
|
||||||
}
|
}
|
||||||
ee.Runner = rc.getRunnerContext(ctx)
|
ee.Runner = rc.getRunnerContext(ctx)
|
||||||
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,
|
||||||
@@ -178,7 +143,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)
|
||||||
@@ -196,7 +161,7 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.
|
|||||||
Mode: 0o644,
|
Mode: 0o644,
|
||||||
Body: hashfiles,
|
Body: hashfiles,
|
||||||
}).
|
}).
|
||||||
Then(rc.execJobContainer([]string{"node", path.Join(rc.JobContainer.GetActPath(), name)},
|
Then(rc.JobContainer.Exec([]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)
|
||||||
@@ -222,6 +187,8 @@ 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)
|
||||||
@@ -246,134 +213,117 @@ func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.N
|
|||||||
return ee.shared(ctx).EvaluateYamlNode(node)
|
return ee.shared(ctx).EvaluateYamlNode(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) (string, error) {
|
||||||
out, err := ee.interpolate(ctx, in)
|
return ee.shared(ctx).Interpolate(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterpolateName keeps the source text of a job or step name that cannot be evaluated, as GitHub does.
|
||||||
|
func (ee expressionEvaluator) InterpolateName(ctx context.Context, in string) string {
|
||||||
|
out, err := ee.Interpolate(ctx, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", in, err)
|
common.Logger(ctx).Warnf("Unable to evaluate the display name '%s': %s", in, err)
|
||||||
return ""
|
return in
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
|
||||||
return ee.shared(ctx).Interpolate(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) {
|
return expreval.New(func(in string, dsc exprparser.DefaultStatusCheck) (any, error) {
|
||||||
return evaluator.evaluate(ctx, in, dsc)
|
return evaluator.evaluate(ctx, in, dsc)
|
||||||
}).EvalBool(expr, defaultStatusCheck)
|
}).EvalBool(expr, defaultStatusCheck)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
func inputsFromEnv(env map[string]string) map[string]any {
|
||||||
inputs := map[string]any{}
|
inputs := map[string]any{}
|
||||||
|
|
||||||
setupWorkflowInputs(ctx, &inputs, rc)
|
|
||||||
|
|
||||||
var env map[string]string
|
|
||||||
if step != nil {
|
|
||||||
env = *step.getEnv()
|
|
||||||
} else {
|
|
||||||
env = rc.GetEnv()
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
if after, ok := strings.CutPrefix(k, "INPUT_"); ok {
|
if after, ok := strings.CutPrefix(k, "INPUT_"); ok {
|
||||||
inputs[strings.ToLower(after)] = v
|
inputs[strings.ToLower(after)] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
|
||||||
if ghc.EventName == "workflow_dispatch" {
|
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
||||||
config := rc.Run.Workflow.WorkflowDispatchConfig()
|
inputs := map[string]any{}
|
||||||
if config != nil && config.Inputs != nil {
|
|
||||||
for k, v := range config.Inputs {
|
// only a called workflow's job itself takes the inputs Gitea resolved,
|
||||||
value := nestedMapLookup(ghc.Event, "inputs", k)
|
// composite actions copy its Config but have their own `inputs`
|
||||||
if value == nil {
|
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
|
||||||
value = v.Default
|
maps.Copy(inputs, rc.Config.WorkflowCallInputs)
|
||||||
}
|
maps.Copy(inputs, stepInputs)
|
||||||
inputs[k] = coerceInputValue(value, v.Type)
|
return inputs
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ghc.EventName == "workflow_call" {
|
maps.Copy(inputs, rc.workflowCallInputs)
|
||||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
maps.Copy(inputs, stepInputs)
|
||||||
if config != nil && config.Inputs != nil {
|
|
||||||
for k, v := range config.Inputs {
|
switch ghc.EventName {
|
||||||
value := nestedMapLookup(ghc.Event, "inputs", k)
|
case "workflow_dispatch":
|
||||||
if value == nil {
|
if config := rc.Run.Workflow.WorkflowDispatchConfig(); config != nil {
|
||||||
value = v.Default
|
for name, input := range config.Inputs {
|
||||||
|
if input.Type == "number" {
|
||||||
|
input.Type = "string" // boolean is the only typed dispatch input, as in Gitea
|
||||||
}
|
}
|
||||||
inputs[k] = coerceInputValue(value, v.Type)
|
setEvaluatorInput(inputs, ghc, name, model.WorkflowCallInput{Type: input.Type, Default: input.Default})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "workflow_call":
|
||||||
|
for name, input := range rc.Run.Workflow.WorkflowCallConfig().Inputs {
|
||||||
|
setEvaluatorInput(inputs, ghc, name, input)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return inputs
|
return inputs
|
||||||
}
|
}
|
||||||
|
|
||||||
// coerceInputValue converts an input value to the type declared by the workflow.
|
func setEvaluatorInput(inputs map[string]any, ghc *model.GithubContext, name string, input model.WorkflowCallInput) {
|
||||||
// The event payload carries natively typed JSON values on newer Gitea versions,
|
value := model.NestedMapLookup(ghc.Event, "inputs", name)
|
||||||
// while defaults and older servers provide strings.
|
if value == nil {
|
||||||
func coerceInputValue(value any, inputType string) any {
|
value, _ = input.DefaultValue()
|
||||||
if inputType != "boolean" {
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
if b, ok := value.(bool); ok {
|
inputs[name] = model.CoerceInputValue(value, input.Type)
|
||||||
return b
|
|
||||||
}
|
|
||||||
return value == "true"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
|
// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
|
||||||
if rc.caller != nil {
|
func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
|
||||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
if rc.caller == nil {
|
||||||
|
return nil
|
||||||
for name, input := range config.Inputs {
|
|
||||||
value := rc.caller.runContext.Run.Job().With[name]
|
|
||||||
if value != nil {
|
|
||||||
if str, ok := value.(string); ok {
|
|
||||||
// evaluate using the calling RunContext (outside)
|
|
||||||
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if value == nil && config != nil && config.Inputs != nil {
|
|
||||||
value = input.Default
|
|
||||||
if rc.ExprEval != nil {
|
|
||||||
if str, ok := value.(string); ok {
|
|
||||||
// evaluate using the called RunContext (inside)
|
|
||||||
value = rc.ExprEval.Interpolate(ctx, str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(*inputs)[name] = coerceInputValue(value, input.Type)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
callerJob := rc.caller.runContext.Run.Job()
|
||||||
|
callerEval := rc.caller.runContext.ExprEval.shared(ctx)
|
||||||
|
var callerWith map[string]any
|
||||||
|
if err := model.DecodeEvaluated("workflow inputs", callerJob.RawWith, callerEval.EvaluateYamlNode, &callerWith); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !rc.Run.Workflow.IsWorkflowCall() {
|
||||||
|
logger.Warn("workflow_call key is not defined in the referenced workflow")
|
||||||
|
}
|
||||||
|
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||||
|
var err error
|
||||||
|
if rc.workflowCallInputs, err = callerEval.ResolveWorkflowCallInputs(config, callerWith); err != nil {
|
||||||
|
logger.Warn(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if callerJob.InheritSecrets() {
|
||||||
|
rc.workflowCallSecrets = getWorkflowSecrets(rc.caller.runContext)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
secrets := callerJob.Secrets()
|
||||||
|
if err := config.ValidateSecrets(secrets); err != nil {
|
||||||
|
logger.Warn(err)
|
||||||
|
}
|
||||||
|
if rc.workflowCallSecrets, err = callerEval.EvaluateCallerSecrets(secrets); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
|
func getWorkflowSecrets(rc *RunContext) map[string]string {
|
||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
job := rc.caller.runContext.Run.Job()
|
return rc.workflowCallSecrets
|
||||||
secrets := job.Secrets()
|
|
||||||
|
|
||||||
if secrets == nil && job.InheritSecrets() {
|
|
||||||
secrets = rc.caller.runContext.Config.Secrets
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
|
|
||||||
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
|
|
||||||
// in place is a data race.
|
|
||||||
interpolated := make(map[string]string, len(secrets))
|
|
||||||
for k, v := range secrets {
|
|
||||||
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return interpolated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc.Config.Secrets
|
return rc.Config.Secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func TestEvaluateRunContext(t *testing.T) {
|
|||||||
out any
|
out any
|
||||||
errMesg string
|
errMesg string
|
||||||
}{
|
}{
|
||||||
{" 1 ", 1, ""},
|
{" 1 ", 1.0, ""},
|
||||||
// {"1 + 3", "4", ""},
|
// {"1 + 3", "4", ""},
|
||||||
// {"(1 + 3) * -2", "-8", ""},
|
// {"(1 + 3) * -2", "-8", ""},
|
||||||
{"'my text'", "my text", ""},
|
{"'my text'", "my text", ""},
|
||||||
@@ -156,8 +156,10 @@ func TestEvaluateRunContext(t *testing.T) {
|
|||||||
|
|
||||||
func TestEvaluateStep(t *testing.T) {
|
func TestEvaluateStep(t *testing.T) {
|
||||||
rc := createRunContext(t)
|
rc := createRunContext(t)
|
||||||
|
rc.Env["INPUT_FORGED"] = "leaked"
|
||||||
step := &stepRun{
|
step := &stepRun{
|
||||||
RunContext: rc,
|
RunContext: rc,
|
||||||
|
env: map[string]string{"INPUT_FORGED": "leaked"},
|
||||||
}
|
}
|
||||||
|
|
||||||
ee := rc.NewStepExpressionEvaluator(context.Background(), step)
|
ee := rc.NewStepExpressionEvaluator(context.Background(), step)
|
||||||
@@ -176,6 +178,7 @@ func TestEvaluateStep(t *testing.T) {
|
|||||||
{"steps.id_with_underscores.conclusion", model.StepStatusSuccess.String(), ""},
|
{"steps.id_with_underscores.conclusion", model.StepStatusSuccess.String(), ""},
|
||||||
{"steps.id_with_underscores.outcome", model.StepStatusFailure.String(), ""},
|
{"steps.id_with_underscores.outcome", model.StepStatusFailure.String(), ""},
|
||||||
{"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores", ""},
|
{"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores", ""},
|
||||||
|
{"inputs.forged", nil, ""}, // INPUT_* env is not an input
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
@@ -263,19 +266,20 @@ func TestInterpolate(t *testing.T) {
|
|||||||
{"${{ null }}", ""},
|
{"${{ null }}", ""},
|
||||||
{"${{ fromJSON('[1,2]') }}", "Array"},
|
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||||
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||||
// a malformed part must not restructure its neighbours, and it interpolates to nothing
|
|
||||||
{"${{ 1) && (2 }}", ""},
|
|
||||||
{"run ${{ 1) && (2 }} now", ""},
|
|
||||||
{"${{ 1", "${{ 1"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run("interpolate", func(t *testing.T) {
|
t.Run("interpolate", func(t *testing.T) {
|
||||||
assertObject := assert.New(t)
|
out, err := ee.Interpolate(context.Background(), table.in)
|
||||||
out := ee.Interpolate(context.Background(), table.in)
|
require.NoError(t, err, table.in)
|
||||||
assertObject.Equal(table.out, out, table.in)
|
assert.Equal(t, table.out, out, table.in)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} {
|
||||||
|
_, err := ee.Interpolate(context.Background(), in)
|
||||||
|
assert.Error(t, err, in)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||||
@@ -290,6 +294,8 @@ on:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
default: gitea
|
default: gitea
|
||||||
|
unset:
|
||||||
|
type: boolean
|
||||||
`,
|
`,
|
||||||
"workflow_dispatch": `
|
"workflow_dispatch": `
|
||||||
on:
|
on:
|
||||||
@@ -301,6 +307,8 @@ on:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
default: gitea
|
default: gitea
|
||||||
|
unset:
|
||||||
|
type: boolean
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,10 +357,122 @@ on:
|
|||||||
}
|
}
|
||||||
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
||||||
|
|
||||||
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc)
|
inputs := getEvaluatorInputs(rc, nil, ghc)
|
||||||
assert.Equal(t, table.flag, inputs["flag"])
|
assert.Equal(t, table.flag, inputs["flag"])
|
||||||
assert.Equal(t, "gitea", inputs["name"])
|
assert.Equal(t, "gitea", inputs["name"])
|
||||||
|
assert.Equal(t, false, inputs["unset"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("deferred workflow call inputs", func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(workflows["workflow_call"] + `
|
||||||
|
jobs:
|
||||||
|
test: {}
|
||||||
|
call:
|
||||||
|
uses: ./reuse.yml
|
||||||
|
with: ${{ fromJSON(matrix.args) }}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
workflow.GetJob("test")
|
||||||
|
job := workflow.GetJob("call")
|
||||||
|
rawWith := model.CloneYamlNode(job.RawWith)
|
||||||
|
for _, value := range []string{"true", "false"} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"${{ 'runner' }}"}`})
|
||||||
|
require.NoError(t, err)
|
||||||
|
child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]any{"flag": value == "true", "name": "${{ 'runner' }}", "unset": false}, child.workflowCallInputs)
|
||||||
|
assert.Equal(t, rawWith, job.RawWith)
|
||||||
|
assert.Nil(t, job.With)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEvaluatorInputsResolvedByGitea(t *testing.T) {
|
||||||
|
wf, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
name: {type: string, default: callee}
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
name: {type: string}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}}
|
||||||
|
ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}}
|
||||||
|
|
||||||
|
// the event's own inputs must not override those Gitea resolved
|
||||||
|
job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}}
|
||||||
|
assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc))
|
||||||
|
|
||||||
|
composite := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{}}, Parent: job}
|
||||||
|
assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) {
|
||||||
|
callerWorkflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
jobs:
|
||||||
|
call:
|
||||||
|
uses: ./reuse.yml
|
||||||
|
with: {flag: not-a-boolean}
|
||||||
|
secrets: {undeclared: "${{ secrets.token }}"}
|
||||||
|
inherit:
|
||||||
|
uses: ./reuse.yml
|
||||||
|
secrets: inherit
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
callee, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
flag: {type: boolean, default: true}
|
||||||
|
jobs:
|
||||||
|
test: {}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
notCallable, err := model.ReadWorkflow(strings.NewReader("on: push\njobs:\n test: {}\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
config := &Config{Secrets: map[string]string{"token": "${{ github.token }}"}}
|
||||||
|
resolve := func(callerJobID string, workflow *model.Workflow) *RunContext {
|
||||||
|
parent, err := (&runnerImpl{config: config}).newRunContext(t.Context(), &model.Run{Workflow: callerWorkflow, JobID: callerJobID}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
child, err := (&runnerImpl{config: config, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
called := resolve("call", callee)
|
||||||
|
assert.Equal(t, map[string]any{"flag": true}, called.workflowCallInputs)
|
||||||
|
assert.Equal(t, map[string]string{"undeclared": "${{ github.token }}"}, called.workflowCallSecrets)
|
||||||
|
inherited := resolve("inherit", notCallable)
|
||||||
|
assert.Empty(t, inherited.workflowCallInputs)
|
||||||
|
assert.Equal(t, config.Secrets, inherited.workflowCallSecrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, err := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
+94
-91
@@ -9,7 +9,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -154,11 +154,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
if rc.Run == nil {
|
if rc.Run == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
if err := evaluateJobEnvAndDefaults(ctx, rc); err != nil {
|
||||||
// evaluate environment variables since they can contain
|
reportStepError(ctx, rc, err)
|
||||||
// GitHub's special environment variables.
|
return err
|
||||||
for k, v := range rc.GetEnv() {
|
|
||||||
rc.Env[k] = rc.ExprEval.Interpolate(ctx, v)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -236,75 +234,55 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
// Ahead of the teardown below, while the job environment is still up.
|
// Ahead of the teardown below, while the job environment is still up.
|
||||||
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
||||||
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
|
// swallowed: a bad output fails this job, it must not abandon the rest of the plan
|
||||||
|
if err := info.interpolateOutputs()(ctx); err != nil {
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
} else if err := setJobOutputs(ctx, rc); err != nil {
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
var err error
|
var err error
|
||||||
// jobError == nil keeps a failed job's container alive for post-mortem debugging when
|
// always allow 1 min for stopping and removing the runner, even if we were cancelled
|
||||||
// AutoRemove is off (the act-CLI --rm behavior; the shipped runner always sets
|
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
||||||
// AutoRemove). A cancelled run is not a failure to inspect, and the cancel-path post
|
defer cancel()
|
||||||
// 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
|
|
||||||
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
tryUploadJobSummary(ctx, rc)
|
tryUploadJobSummary(ctx, rc)
|
||||||
// For Gitea
|
logger.Infof("Cleaning up container for job %s", rc.JobName)
|
||||||
// We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer`
|
if err = info.stopContainer()(ctx); err != nil {
|
||||||
// logger.Infof("Cleaning up services for job %s", rc.JobName)
|
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||||
// 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)
|
|
||||||
if err = info.stopContainer()(ctx); err != nil {
|
|
||||||
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)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
|
return common.Executor(func(ctx context.Context) error {
|
||||||
|
if err := info.startContainer()(ctx); err != nil {
|
||||||
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
|
return err
|
||||||
Finally(func(ctx context.Context) error {
|
}
|
||||||
|
return newStepsExecutor(rc, preSteps, steps).Finally(func(ctx context.Context) error {
|
||||||
// Record an interrupt (backstop for interrupts that land outside the main
|
// Record an interrupt (backstop for interrupts that land outside the main
|
||||||
// step loop) so the post steps observe the cancelled/failed job status.
|
// step loop) so the post steps observe the cancelled/failed job status.
|
||||||
rc.markInterrupted(ctx.Err())
|
rc.markInterrupted(ctx.Err())
|
||||||
postCtx, cancel := postStepsContext(ctx)
|
postCtx, cancel := postStepsContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return postExecutor(postCtx)
|
return postExecutor(postCtx)
|
||||||
}).
|
})(ctx)
|
||||||
Finally(info.interpolateOutputs()).
|
}).Finally(info.closeContainer())
|
||||||
Finally(info.closeContainer()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
||||||
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
|
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
|
||||||
// returned context always carries a fresh bounded deadline and is never itself cancelled.
|
// returned context always carries a fresh bounded deadline and is never itself cancelled.
|
||||||
//
|
//
|
||||||
// - context.Canceled (server cancel): detach from the cancelled context via a fresh root so
|
// - context.Canceled (server cancel): detach with WithoutCancel under a fresh job-error
|
||||||
// the post steps can run.
|
// container, so a failing post step cannot turn the cancellation into a failure.
|
||||||
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
|
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
|
||||||
// keeps the original values — including the job-error container — so the timeout failure and
|
// keeps the original values — including the job-error container — so the timeout failure and
|
||||||
// any post-step error are preserved and the job is still reported as failed.
|
// any post-step error are preserved and the job is still reported as failed.
|
||||||
@@ -312,10 +290,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
switch ctx.Err() {
|
switch ctx.Err() {
|
||||||
case context.Canceled:
|
case context.Canceled:
|
||||||
// The cancelled context is abandoned for a fresh root, which drops the job-error
|
return context.WithTimeout(common.WithJobErrorContainer(context.WithoutCancel(ctx)), 5*time.Minute)
|
||||||
// container installed at the job root. Re-attach a fresh one so a failing post step
|
|
||||||
// records its error via SetJobError instead of panicking on a nil container.
|
|
||||||
return context.WithTimeout(common.WithJobErrorContainer(common.WithLogger(context.Background(), common.Logger(ctx))), 5*time.Minute)
|
|
||||||
case context.DeadlineExceeded:
|
case context.DeadlineExceeded:
|
||||||
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||||
default:
|
default:
|
||||||
@@ -393,7 +368,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
// concurrent succeeding one.
|
// concurrent succeeding one.
|
||||||
job := rc.Run.Job()
|
job := rc.Run.Job()
|
||||||
var continueOnError bool
|
var continueOnError bool
|
||||||
if !success {
|
if !success && !rc.jobCancelled {
|
||||||
// Use a fresh context so an expired job timeout cannot block expression evaluation.
|
// Use a fresh context so an expired job timeout cannot block expression evaluation.
|
||||||
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
|
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
|
||||||
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
|
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
|
||||||
@@ -406,7 +381,11 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
if len(info.matrix()) > 0 && job.Result != "" {
|
if len(info.matrix()) > 0 && job.Result != "" {
|
||||||
result = job.Result
|
result = job.Result
|
||||||
}
|
}
|
||||||
if !success {
|
// cancelled is sticky, so a sibling combination finishing last cannot mask it
|
||||||
|
switch {
|
||||||
|
case rc.jobCancelled:
|
||||||
|
result = "cancelled"
|
||||||
|
case !success && result != "cancelled":
|
||||||
result = "failure"
|
result = "failure"
|
||||||
job.SetContinueOnError(continueOnError)
|
job.SetContinueOnError(continueOnError)
|
||||||
}
|
}
|
||||||
@@ -416,43 +395,74 @@ 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.JobName, jobResult) // For Gitea
|
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, jobResult) // For Gitea
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jobResultMessage := "succeeded"
|
jobResultMessage := "failed"
|
||||||
if jobResult != "success" {
|
switch jobResult {
|
||||||
jobResultMessage = "failed"
|
case "success":
|
||||||
|
jobResultMessage = "succeeded"
|
||||||
|
case "cancelled":
|
||||||
|
jobResultMessage = "cancelled"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setJobOutputs(ctx context.Context, rc *RunContext) {
|
// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup.
|
||||||
if rc.caller != nil {
|
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
||||||
// map outputs for reusable workflows
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
callerOutputs := make(map[string]string)
|
env := rc.GetEnv()
|
||||||
|
var workflowEnv map[string]string
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
if rc.Run.Workflow.Env == nil {
|
||||||
|
if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
|
||||||
for k, v := range rc.Run.Workflow.WorkflowCallConfig().Outputs {
|
return err
|
||||||
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
|
||||||
// serialize the write so parallel combos don't race on its Outputs field.
|
|
||||||
callerJob := rc.caller.runContext.Run.Job()
|
|
||||||
defer lockJob(callerJob)()
|
|
||||||
callerJob.Outputs = callerOutputs
|
|
||||||
}
|
}
|
||||||
|
if workflowEnv != nil {
|
||||||
|
rc.Env = mergeMaps(workflowEnv, env)
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
for k, v := range env {
|
||||||
|
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var defaults model.Defaults
|
||||||
|
if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc.jobRunDefaults = defaults.Run
|
||||||
|
return model.DecodeEvaluated("container env", rc.containerEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &rc.containerSpec.Env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setJobOutputs(ctx context.Context, rc *RunContext) error {
|
||||||
|
if rc.caller == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
callerOutputs, err := rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateWorkflowCallOutputs(rc.Run.Workflow.WorkflowCallConfig())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate workflow %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
||||||
|
// serialize the write so parallel combos don't race on its Outputs field.
|
||||||
|
callerJob := rc.caller.runContext.Run.Job()
|
||||||
|
defer lockJob(callerJob)()
|
||||||
|
callerJob.Outputs = callerOutputs
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
|
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
|
||||||
// step-level evaluateStepTimeout in step.go.
|
// step-level evaluateStepTimeout in step.go.
|
||||||
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
|
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
|
||||||
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
timeout, err := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
||||||
if timeout != "" {
|
if err != nil {
|
||||||
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
|
||||||
|
} else if timeout != "" {
|
||||||
|
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeoutMinutes > 0 {
|
||||||
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -515,7 +525,8 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
|||||||
if !ok || len(body) == 0 {
|
if !ok || len(body) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, body)
|
// Gitea renders summaries on the run page, so mask before the upload.
|
||||||
|
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, []byte(rc.maskSecrets(string(body))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,17 +660,9 @@ func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
|||||||
|
|
||||||
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String())
|
||||||
|
|
||||||
rawLogger := common.Logger(ctx).WithField("raw_output", true)
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
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)
|
||||||
|
|||||||
+129
-30
@@ -299,6 +299,9 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executedSteps []string
|
executedSteps []string
|
||||||
result string
|
result string
|
||||||
hasError bool
|
hasError bool
|
||||||
|
output string
|
||||||
|
startError error
|
||||||
|
cancelOnStart bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "zeroSteps",
|
name: "zeroSteps",
|
||||||
@@ -319,8 +322,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executedSteps: []string{
|
executedSteps: []string{
|
||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -337,6 +340,7 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "failure",
|
result: "failure",
|
||||||
@@ -353,8 +357,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"startContainer",
|
"startContainer",
|
||||||
"pre1",
|
"pre1",
|
||||||
"step1",
|
"step1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -371,8 +375,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"post1",
|
"post1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -390,8 +394,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"pre1",
|
"pre1",
|
||||||
"step1",
|
"step1",
|
||||||
"post1",
|
"post1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -417,13 +421,37 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"step3",
|
"step3",
|
||||||
"post3",
|
"post3",
|
||||||
"post2",
|
"post2",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
hasError: false,
|
hasError: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "jobOutputExpressionFailure",
|
||||||
|
steps: []*model.Step{{ID: "1"}},
|
||||||
|
preSteps: []bool{false},
|
||||||
|
postSteps: []bool{false},
|
||||||
|
executedSteps: []string{"startContainer", "step1", "interpolateOutputs", "stopContainer", "closeContainer"},
|
||||||
|
result: "failure",
|
||||||
|
output: "${{ 'test' != test }}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start failure",
|
||||||
|
steps: []*model.Step{{ID: "1"}},
|
||||||
|
executedSteps: []string{"startContainer", "closeContainer"},
|
||||||
|
startError: errors.New("start failed"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancelled at startup boundary",
|
||||||
|
steps: []*model.Step{{ID: "1"}},
|
||||||
|
preSteps: []bool{false},
|
||||||
|
postSteps: []bool{true},
|
||||||
|
executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"},
|
||||||
|
result: "cancelled",
|
||||||
|
cancelOnStart: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
contains := func(needle string, haystack []string) bool {
|
contains := func(needle string, haystack []string) bool {
|
||||||
@@ -434,7 +462,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
|
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
|
||||||
ctx := common.WithJobErrorContainer(context.Background())
|
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||||
|
defer cancel()
|
||||||
jim := &jobInfoMock{}
|
jim := &jobInfoMock{}
|
||||||
sfm := &stepFactoryMock{}
|
sfm := &stepFactoryMock{}
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
@@ -449,15 +478,21 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
}
|
}
|
||||||
|
if tt.output != "" {
|
||||||
|
require.NoError(t, rc.Run.Job().RawOutputs.Encode(map[string]string{"bad": tt.output}))
|
||||||
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
executorOrder := make([]string, 0)
|
executorOrder := make([]string, 0)
|
||||||
|
|
||||||
jim.On("steps").Return(tt.steps)
|
jim.On("steps").Return(tt.steps)
|
||||||
|
|
||||||
if len(tt.steps) > 0 {
|
if len(tt.steps) > 0 {
|
||||||
jim.On("startContainer").Return(func(ctx context.Context) error {
|
jim.On("startContainer").Return(func(_ context.Context) error {
|
||||||
executorOrder = append(executorOrder, "startContainer")
|
executorOrder = append(executorOrder, "startContainer")
|
||||||
return nil
|
if tt.cancelOnStart {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
return tt.startError
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,23 +526,31 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
defer sm.AssertExpectations(t)
|
defer sm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tt.steps) > 0 {
|
if len(tt.steps) > 0 && tt.startError == nil {
|
||||||
jim.On("matrix").Return(map[string]any{})
|
jim.On("matrix").Return(map[string]any{})
|
||||||
|
|
||||||
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "interpolateOutputs")
|
executorOrder = append(executorOrder, "interpolateOutputs")
|
||||||
|
if tt.output != "" {
|
||||||
|
return rc.interpolateOutputs()(ctx)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if contains("stopContainer", tt.executedSteps) {
|
if contains("stopContainer", tt.executedSteps) {
|
||||||
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "stopContainer")
|
executorOrder = append(executorOrder, "stopContainer")
|
||||||
|
require.NoError(t, ctx.Err())
|
||||||
|
_, bounded := ctx.Deadline()
|
||||||
|
require.True(t, bounded)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
jim.On("result", tt.result)
|
jim.On("result", tt.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tt.steps) > 0 {
|
||||||
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "closeContainer")
|
executorOrder = append(executorOrder, "closeContainer")
|
||||||
return nil
|
return nil
|
||||||
@@ -516,7 +559,15 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
|
|
||||||
executor := newJobExecutor(jim, sfm, rc)
|
executor := newJobExecutor(jim, sfm, rc)
|
||||||
err := executor(ctx)
|
err := executor(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
switch {
|
||||||
|
case tt.startError != nil:
|
||||||
|
require.ErrorIs(t, err, tt.startError)
|
||||||
|
case tt.cancelOnStart:
|
||||||
|
require.ErrorIs(t, err, context.Canceled)
|
||||||
|
default:
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
assert.Empty(t, rc.Run.Job().Outputs["bad"])
|
||||||
assert.Equal(t, tt.executedSteps, executorOrder)
|
assert.Equal(t, tt.executedSteps, executorOrder)
|
||||||
|
|
||||||
jim.AssertExpectations(t)
|
jim.AssertExpectations(t)
|
||||||
@@ -527,18 +578,39 @@ 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 := common.WithJobErrorContainer(context.Background())
|
ctx := newControllableDeadlineContext(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{}
|
||||||
@@ -562,19 +634,16 @@ 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. stopContainer is left
|
// The job timed out, so it must be reported as failed and still cleaned up.
|
||||||
// unexpected on purpose: a timed-out (failed) job preserves its error state, so
|
jim.On("stopContainer").Return(func(context.Context) error { return nil })
|
||||||
// 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 })
|
||||||
// The main step runs past the job timeout: it blocks until the job context is
|
sm.On("main").Return(func(stepCtx context.Context) error {
|
||||||
// done, mirroring a step that overruns timeout-minutes.
|
ctx.expire()
|
||||||
sm.On("main").Return(func(ctx context.Context) error {
|
return stepCtx.Err()
|
||||||
<-ctx.Done()
|
|
||||||
return ctx.Err()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
var postRan bool
|
var postRan bool
|
||||||
@@ -984,12 +1053,7 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
|
|||||||
|
|
||||||
func newTestRC(wf *model.Workflow, matrix map[string]any) *RunContext {
|
func newTestRC(wf *model.Workflow, matrix map[string]any) *RunContext {
|
||||||
return &RunContext{
|
return &RunContext{
|
||||||
Config: &Config{
|
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }},
|
||||||
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,
|
||||||
@@ -1017,6 +1081,7 @@ func TestApplyJobTimeout(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"empty", "runs-on: ubuntu-latest", false},
|
{"empty", "runs-on: ubuntu-latest", false},
|
||||||
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true},
|
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true},
|
||||||
|
{"zero ignored", "timeout-minutes: 0\nruns-on: ubuntu-latest", false},
|
||||||
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
|
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -1082,3 +1147,37 @@ 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)
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -51,10 +50,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
|||||||
rawLogger.Infof("shell: %s", shell)
|
rawLogger.Infof("shell: %s", shell)
|
||||||
}
|
}
|
||||||
|
|
||||||
env := maps.Clone(rc.GetEnv())
|
env := mergeMaps(rc.containerSpec.Env, rc.GetEnv())
|
||||||
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
|
||||||
maps.Copy(env, jobContainer.Env)
|
|
||||||
}
|
|
||||||
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
||||||
rc.ApplyExtraPath(ctx, &env)
|
rc.ApplyExtraPath(ctx, &env)
|
||||||
|
|
||||||
@@ -64,7 +60,9 @@ 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.
|
||||||
err = cmp.Or(err, rc.processHookFileCommands(ctx))
|
if processErr := rc.processHookFileCommands(ctx); err == nil {
|
||||||
|
err = processErr
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -93,10 +91,11 @@ func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
||||||
if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil {
|
err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnvFile)
|
||||||
return err
|
if pathErr := rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand)); pathErr != nil && err == nil {
|
||||||
|
err = pathErr
|
||||||
}
|
}
|
||||||
return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand))
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
+138
-84
@@ -8,7 +8,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json/jsontext"
|
||||||
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -78,7 +79,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)
|
||||||
@@ -99,10 +100,7 @@ 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{
|
formatter = &jobLogFormatter{color: colors[nextColor%len(colors)]}
|
||||||
color: colors[nextColor%len(colors)],
|
|
||||||
logPrefixJobID: config.LogPrefixJobID,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger = logrus.New()
|
logger = logrus.New()
|
||||||
@@ -124,7 +122,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.Secrets),
|
masker: valueMasker(config.InsecureSecrets, config.maskers()),
|
||||||
})
|
})
|
||||||
rtn := logger.WithFields(logrus.Fields{
|
rtn := logger.WithFields(logrus.Fields{
|
||||||
"job": jobName,
|
"job": jobName,
|
||||||
@@ -170,55 +168,83 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
|
|||||||
|
|
||||||
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
||||||
|
|
||||||
// secretValueEncoders are the shapes a secret takes on its way into a log: a base64
|
|
||||||
// payload, a JSON string, or a URL component. An action that serializes a secret leaks
|
|
||||||
// it in one of these forms, which a mask of the verbatim value alone does not catch, so
|
|
||||||
// every form is masked as well. This mirrors the value encoders of GitHub's runner.
|
|
||||||
var secretValueEncoders = []func(string) string{
|
var secretValueEncoders = []func(string) string{
|
||||||
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
||||||
base64ShiftEncoder(1),
|
base64ShiftEncoder(1),
|
||||||
base64ShiftEncoder(2),
|
base64ShiftEncoder(2),
|
||||||
|
base64InteriorEncoder(0),
|
||||||
|
base64InteriorEncoder(1),
|
||||||
|
base64InteriorEncoder(2),
|
||||||
|
expressionStringEscape,
|
||||||
jsonStringEscape,
|
jsonStringEscape,
|
||||||
jsonStringEscapeNoHTML,
|
jsonStringEscapeNoHTML,
|
||||||
url.QueryEscape,
|
uriDataEscape,
|
||||||
|
url.QueryEscape, // the form-encoded twin of uriDataEscape, which spells a space "+"
|
||||||
url.PathEscape,
|
url.PathEscape,
|
||||||
|
xmlDataEscape,
|
||||||
|
trimDoubleQuotes,
|
||||||
}
|
}
|
||||||
|
|
||||||
// minShiftedBase64Len is the shortest shifted base64 fragment worth masking. A shorter
|
// base64ShiftEncoder reproduces the 3-byte alignments of `Basic base64("user:token")`, and
|
||||||
// one carries too few bytes of the secret to identify it and would mask unrelated output.
|
// its padded tail only matches a secret that ends the payload.
|
||||||
const minShiftedBase64Len = 8
|
|
||||||
|
|
||||||
// base64ShiftEncoder returns the part of a secret's base64 form that survives when the
|
|
||||||
// secret does not start on a 3-byte boundary of the payload it is embedded in. base64
|
|
||||||
// encodes three bytes at a time, so `Authorization: Basic base64("user:token")` contains
|
|
||||||
// the base64 of the token alone only when the prefix length happens to be a multiple of
|
|
||||||
// three; at the other two alignments the encoding of the whole value differs. Encoding
|
|
||||||
// the secret behind shift filler bytes reproduces those alignments, which is what the
|
|
||||||
// Base64StringEscapeShift1/2 encoders of GitHub's runner do.
|
|
||||||
//
|
|
||||||
// The leading group (filler mixed with the secret's first bytes) and the trailing group
|
|
||||||
// (padded here, but continuing into whatever follows the secret) are dropped, leaving the
|
|
||||||
// group-aligned middle that does appear verbatim in the log.
|
|
||||||
func base64ShiftEncoder(shift int) func(string) string {
|
func base64ShiftEncoder(shift int) func(string) string {
|
||||||
|
return func(v string) string {
|
||||||
|
value := []byte(v)
|
||||||
|
if len(value) > shift {
|
||||||
|
value = value[shift:]
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minInteriorBase64Len = 8 // below this a fragment matches unrelated output
|
||||||
|
|
||||||
|
// base64InteriorEncoder keeps the aligned middle, so a secret with data after it still matches.
|
||||||
|
func base64InteriorEncoder(shift int) func(string) string {
|
||||||
return func(v string) string {
|
return func(v string) string {
|
||||||
buf := make([]byte, shift+len(v))
|
buf := make([]byte, shift+len(v))
|
||||||
copy(buf[shift:], v)
|
copy(buf[shift:], v)
|
||||||
encoded := base64.StdEncoding.EncodeToString(buf)
|
encoded := base64.StdEncoding.EncodeToString(buf)
|
||||||
// Keep only the aligned middle, and only when enough of it is left to be a
|
if len(encoded) < 8+minInteriorBase64Len {
|
||||||
// distinctive pattern rather than a fragment that matches unrelated output.
|
|
||||||
if len(encoded) < 8+minShiftedBase64Len {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return encoded[4 : len(encoded)-4]
|
return encoded[4 : len(encoded)-4]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func expressionStringEscape(v string) string {
|
||||||
|
return strings.ReplaceAll(v, "'", "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
func uriDataEscape(v string) string {
|
||||||
|
return strings.ReplaceAll(url.QueryEscape(v), "+", "%20")
|
||||||
|
}
|
||||||
|
|
||||||
|
var xmlDataEscaper = strings.NewReplacer(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
`"`, """,
|
||||||
|
"'", "'",
|
||||||
|
)
|
||||||
|
|
||||||
|
func xmlDataEscape(v string) string {
|
||||||
|
return xmlDataEscaper.Replace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimDoubleQuotes(v string) string {
|
||||||
|
if len(v) > 8 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) {
|
||||||
|
return v[1 : len(v)-1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
|
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
|
||||||
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
|
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
|
||||||
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
||||||
// 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)
|
encoded, err := json.Marshal(v, jsontext.EscapeForHTML(true))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -229,59 +255,60 @@ 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 {
|
||||||
var buf bytes.Buffer
|
encoded, err := json.Marshal(v)
|
||||||
enc := json.NewEncoder(&buf)
|
if err != nil {
|
||||||
enc.SetEscapeHTML(false)
|
|
||||||
if err := enc.Encode(v); err != nil {
|
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
// Encode appends a newline; drop it along with the surrounding quotes.
|
return string(encoded[1 : len(encoded)-1])
|
||||||
encoded := strings.TrimRight(buf.String(), "\n")
|
|
||||||
return encoded[1 : len(encoded)-1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
// AppendSecretMaskers skips the debug settings, as GitHub does: they arrive as secrets, but
|
||||||
ret := oldnew
|
// masking "true" would corrupt unrelated log lines and drop job outputs that say it.
|
||||||
|
func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
|
||||||
for l := range strings.SplitSeq(v, "\n") {
|
for k, v := range secrets {
|
||||||
tm := strings.TrimSpace(l)
|
if k != "ACTIONS_STEP_DEBUG" && k != "ACTIONS_RUNNER_DEBUG" {
|
||||||
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
oldnew = AppendSecretMasker(oldnew, v)
|
||||||
if len(tm) > 1 {
|
|
||||||
ret = append(ret, tm, "***")
|
|
||||||
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
|
||||||
if strings.ContainsAny(tm, "%\r\n") {
|
|
||||||
ret = append(ret, EscapeCommandData(tm), "***")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return oldnew
|
||||||
|
}
|
||||||
|
|
||||||
// The encoded forms are derived from the whole value: a multi-line secret is
|
// AppendSecretMasker registers v and each of its lines, as GitHub does.
|
||||||
// encoded as one string, not line by line.
|
func AppendSecretMasker(oldnew []string, v string) []string {
|
||||||
trimmed := strings.TrimSpace(v)
|
ret := appendMaskedValue(oldnew, v)
|
||||||
if len(trimmed) <= 1 {
|
for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
|
||||||
|
ret = appendMaskedValue(ret, strings.TrimSpace(l))
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendMaskedValue registers one value and every shape it takes on its way into a log.
|
||||||
|
func appendMaskedValue(ret []string, v string) []string {
|
||||||
|
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
||||||
|
if len(strings.TrimSpace(v)) <= 1 || slices.Contains(ret, v) {
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
ret = append(ret, v, "***")
|
||||||
|
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
||||||
|
if strings.ContainsAny(v, "%\r\n") {
|
||||||
|
ret = append(ret, EscapeCommandData(v), "***")
|
||||||
|
}
|
||||||
for _, encode := range secretValueEncoders {
|
for _, encode := range secretValueEncoders {
|
||||||
encoded := encode(trimmed)
|
encoded := encode(v)
|
||||||
// An encoding that leaves the value unchanged is already masked above.
|
// An encoding that leaves the value unchanged is already masked above.
|
||||||
if encoded == trimmed || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ret = append(ret, encoded, "***")
|
ret = append(ret, encoded, "***")
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, secrets map[string]string) entryProcessor {
|
func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
||||||
var oldnew []string
|
|
||||||
for _, v := range secrets {
|
|
||||||
oldnew = AppendSecretMasker(oldnew, v)
|
|
||||||
}
|
|
||||||
oldnew = slices.Clip(oldnew)
|
oldnew = slices.Clip(oldnew)
|
||||||
defReplacer := strings.NewReplacer(oldnew...)
|
defReplacer := NewSecretReplacer(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
|
||||||
@@ -317,7 +344,7 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
|
|||||||
pairs = AppendSecretMasker(pairs, v)
|
pairs = AppendSecretMasker(pairs, v)
|
||||||
}
|
}
|
||||||
masked = len(*masks)
|
masked = len(*masks)
|
||||||
replacer = strings.NewReplacer(pairs...)
|
replacer = NewSecretReplacer(pairs)
|
||||||
}
|
}
|
||||||
cmasker := replacer
|
cmasker := replacer
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
@@ -338,8 +365,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,27 +389,23 @@ 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")
|
||||||
|
|
||||||
var job any
|
job := entry.Data["job"]
|
||||||
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] "
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Data[rawOutputField] == true {
|
switch {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
} else if entry.Data["dryrun"] == true {
|
case 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)
|
||||||
} else {
|
default:
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,23 +413,19 @@ 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")
|
||||||
|
|
||||||
var job any
|
job := entry.Data["job"]
|
||||||
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] "
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Data[rawOutputField] == true {
|
switch {
|
||||||
|
case entry.Data[rawOutputField] == true:
|
||||||
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
||||||
} else if entry.Data["dryrun"] == true {
|
case 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)
|
||||||
} else {
|
default:
|
||||||
fmt.Fprintf(b, "[%s] %s%s", job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "[%s] %s%s", job, debugFlag, entry.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,3 +452,39 @@ 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...)
|
||||||
|
}
|
||||||
+48
-34
@@ -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, entry.secrets)
|
masker := valueMasker(false, AppendSecretMaskers(nil, 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,
|
||||||
@@ -64,26 +64,27 @@ func TestValueMasker(t *testing.T) {
|
|||||||
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
|
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
|
||||||
// URL — must be masked as well: masking only the verbatim value leaks it.
|
// URL — must be masked as well: masking only the verbatim value leaks it.
|
||||||
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||||
secret := `p@ss w"rd/1`
|
|
||||||
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name, secret string
|
||||||
line string
|
encoded []string
|
||||||
}{
|
}{
|
||||||
{"verbatim", "the token is " + secret},
|
{"common encodings", `p@ss w"rd/1`, []string{
|
||||||
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))},
|
`p@ss w"rd/1`, base64.StdEncoding.EncodeToString([]byte(`p@ss w"rd/1`)),
|
||||||
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`},
|
jsonStringEscape(`p@ss w"rd/1`), url.PathEscape(`p@ss w"rd/1`),
|
||||||
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)},
|
}},
|
||||||
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"},
|
{"XML expression and quotes", `"a'b&c<d>"`, []string{
|
||||||
|
`"a'b&c<d>"`, `"a''b&c<d>"`, `a'b&c<d>`,
|
||||||
|
}},
|
||||||
|
{"URI spaces", "a b", []string{"a%20b", "a+b"}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line})
|
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": tc.secret}))
|
||||||
|
entry := masker(&logrus.Entry{Context: t.Context(), Message: strings.Join(tc.encoded, " ")})
|
||||||
|
|
||||||
assert.Contains(t, entry.Message, "***")
|
assert.Contains(t, entry.Message, "***")
|
||||||
assert.NotContains(t, entry.Message, secret)
|
for _, disallowed := range tc.encoded {
|
||||||
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret)))
|
assert.NotContains(t, entry.Message, disallowed)
|
||||||
assert.NotContains(t, entry.Message, url.QueryEscape(secret))
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,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, map[string]string{"TOKEN": secret})
|
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -112,17 +113,33 @@ 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", "first\rsecond", " s3cr3t "}
|
||||||
masker := valueMasker(false, nil)
|
masker := valueMasker(false, AppendSecretMaskers(nil, nil))
|
||||||
|
|
||||||
entry := masker(&logrus.Entry{
|
for _, tc := range []struct {
|
||||||
Context: WithMasks(t.Context(), &masks),
|
line, want string
|
||||||
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")),
|
}{
|
||||||
})
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")), "encoded: ***"},
|
||||||
|
{"first and second", "*** and ***"},
|
||||||
assert.Equal(t, "encoded: ***", entry.Message)
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
|
||||||
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
|
||||||
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
|
||||||
|
} {
|
||||||
|
entry := masker(&logrus.Entry{Context: WithMasks(t.Context(), &masks), Message: tc.line})
|
||||||
|
assert.Equal(t, tc.want, entry.Message, tc.line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
||||||
@@ -130,8 +147,8 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
|
|||||||
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
||||||
// the token to anyone who can decode the log.
|
// the token to anyone who can decode the log.
|
||||||
func TestValueMaskerBase64Alignments(t *testing.T) {
|
func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||||
secret := "s3cr3t-token-value"
|
secret := "s3cr3t"
|
||||||
masker := valueMasker(false, map[string]string{"TOKEN": secret})
|
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
||||||
|
|
||||||
// One prefix per alignment: len%3 of 0, 1 and 2.
|
// One prefix per alignment: len%3 of 0, 1 and 2.
|
||||||
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
||||||
@@ -140,8 +157,6 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
|
|||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
||||||
|
|
||||||
assert.Contains(t, entry.Message, "***")
|
assert.Contains(t, entry.Message, "***")
|
||||||
// The aligned middle of the secret must be gone, so the payload can no longer be
|
|
||||||
// decoded back into the token.
|
|
||||||
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
||||||
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
||||||
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
||||||
@@ -155,7 +170,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, map[string]string{"TOKEN": "secret-token"})
|
masker := valueMasker(false, AppendSecretMaskers(nil, 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
|
||||||
}
|
}
|
||||||
@@ -180,15 +195,14 @@ func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
|||||||
// JSON, query and path escaping all leave it unchanged.
|
// JSON, query and path escaping all leave it unchanged.
|
||||||
pairs := AppendSecretMasker(nil, "plaintoken")
|
pairs := AppendSecretMasker(nil, "plaintoken")
|
||||||
assert.Equal(t, []string{
|
assert.Equal(t, []string{
|
||||||
"plaintoken", "***",
|
"plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
|
||||||
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***",
|
"aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
|
||||||
// The two shifted alignments, each without its leading and trailing group.
|
|
||||||
"YWludG9r", "***",
|
|
||||||
"bGFpbnRv", "***",
|
|
||||||
}, pairs)
|
}, pairs)
|
||||||
|
|
||||||
// Too short to mask.
|
// Too short to mask.
|
||||||
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
||||||
|
assert.Empty(t, AppendSecretMasker(nil, " \t"))
|
||||||
|
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,9 +35,14 @@ func TestMaxParallelStrategy(t *testing.T) {
|
|||||||
expectedMaxParallel: 4,
|
expectedMaxParallel: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "max-parallel-10",
|
name: "max-parallel-10-clamped-to-combinations",
|
||||||
maxParallelString: "10",
|
maxParallelString: "10",
|
||||||
expectedMaxParallel: 10,
|
expectedMaxParallel: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max-parallel-invalid-falls-back-to-default",
|
||||||
|
maxParallelString: "tow",
|
||||||
|
expectedMaxParallel: 4,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +67,55 @@ func TestMaxParallelStrategy(t *testing.T) {
|
|||||||
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.NotNil(t, matrixes)
|
assert.NotNil(t, matrixes)
|
||||||
assert.Len(t, matrixes, 5)
|
assert.Len(t, matrixes, 5)
|
||||||
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
|
assert.Equal(t, tt.expectedMaxParallel, maxParallelFor(job.Strategy, len(matrixes)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("deferred strategy", func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
if: false
|
||||||
|
strategy: ${{ fromJSON('{"max-parallel":2,"fail-fast":false,"matrix":{"os":["ubuntu","windows"]}}') }}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
job := workflow.Jobs["test"]
|
||||||
|
rawStrategy := model.CloneYamlNode(job.RawStrategy)
|
||||||
|
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
|
||||||
|
require.NotNil(t, job.Strategy)
|
||||||
|
assert.False(t, job.Strategy.GetFailFast())
|
||||||
|
assert.Equal(t, 2, maxParallelFor(job.Strategy, 5))
|
||||||
|
matrixes, err := job.GetMatrixes()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []map[string]any{{"os": "ubuntu"}, {"os": "windows"}}, matrixes)
|
||||||
|
assert.Equal(t, rawStrategy, job.RawStrategy)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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:")
|
||||||
|
|
||||||
|
for _, strategy := range []string{
|
||||||
|
`${{ fromJSON('invalid') }}`,
|
||||||
|
`${{ '${{ inputs.unresolved }}' }}`,
|
||||||
|
} {
|
||||||
|
t.Run(strategy, func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n test:\n strategy: " + strategy))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, runner.NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
||||||
|
// from working against Gitea. Both are edited out of the bundle the runner downloaded.
|
||||||
|
//
|
||||||
|
// isGhes() takes any host that is not github.com, *.ghe.com or *.localhost for GitHub
|
||||||
|
// Enterprise. @actions/cache then forces the v1 API, and @actions/artifact refuses outright,
|
||||||
|
// which is why the stock upload-artifact aborts here. The edit empties the last of the three
|
||||||
|
// hostname tests, so `endsWith('.LOCALHOST')` becomes `endsWith(”)`, which every hostname
|
||||||
|
// satisfies: one string literal, no call sites to resolve, and the same answer the toolkit's own
|
||||||
|
// proposed ACTIONS_VENDOR switch would give. Gitea already makes this edit by hand in its fork
|
||||||
|
// of upload-artifact.
|
||||||
|
//
|
||||||
|
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
|
||||||
|
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, so a patched cache
|
||||||
|
// client reaches the cache server by its own address rather than through the results origin.
|
||||||
|
//
|
||||||
|
// Either of these landing upstream makes this file deletable:
|
||||||
|
//
|
||||||
|
// https://github.com/actions/toolkit/pull/2123 — an ACTIONS_VENDOR switch, naming Gitea
|
||||||
|
// https://github.com/actions/toolkit/issues/2439 — treat ACTIONS_RESULTS_URL as the signal
|
||||||
|
const (
|
||||||
|
CacheServiceV2Env = "ACTIONS_CACHE_SERVICE_V2"
|
||||||
|
cacheURLEnv = "ACTIONS_CACHE_URL"
|
||||||
|
resultsURLEnv = "ACTIONS_RESULTS_URL"
|
||||||
|
|
||||||
|
// localhostHost is the suffix isGhes accepts.
|
||||||
|
localhostHost = ".LOCALHOST"
|
||||||
|
|
||||||
|
// artifactRefusal is the only thing the gate guards in @actions/artifact, which is what makes
|
||||||
|
// such a bundle safe to open. A bundle carrying neither toolkit uses isGhes for something this
|
||||||
|
// runner has not looked at, and is left alone.
|
||||||
|
artifactRefusal = "GHESNotSupportedError"
|
||||||
|
|
||||||
|
maxBundleSize = 64 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// localhostTest matches the third hostname test of isGhes, in any quoting. The match is case
|
||||||
|
// sensitive on purpose, and that is load-bearing: isGhes uppercases the hostname before
|
||||||
|
// testing it, while undici, bundled into all of these actions, tests a lowercase ".localhost"
|
||||||
|
// in isURLPotentiallyTrustworthy. Opening that one would tell its HTTP client that every URL
|
||||||
|
// is trustworthy. Uppercase, the literal occurs nowhere but this test, across 118 bundles
|
||||||
|
// covering every major version of sixteen actions.
|
||||||
|
localhostTest = regexp.MustCompile(`endsWith\s*\(\s*` + quoted(regexp.QuoteMeta(localhostHost)) + `\s*\)`)
|
||||||
|
|
||||||
|
// serviceURLBranches matches both branches of getCacheServiceURL at once: the v1 branch reads
|
||||||
|
// the cache URL and falls back to the results URL, the v2 branch just below reads the results
|
||||||
|
// URL alone. That `||` pairing is the only place the two variables are read together, so
|
||||||
|
// matching them as one expression is what keeps the edit inside this function rather than
|
||||||
|
// anywhere they happen to sit near each other. The branches are 21 bytes apart minified and
|
||||||
|
// 63 not, across every bundle measured.
|
||||||
|
serviceURLBranches = regexp.MustCompile(`(` + envRead(cacheURLEnv) + `\s*\|\|\s*)(` +
|
||||||
|
envRead(resultsURLEnv) + `)((?s).{0,256}?)(` + envRead(resultsURLEnv) + `)`)
|
||||||
|
|
||||||
|
// cacheURLFirst gives both reads the preference the v1 branch already had.
|
||||||
|
cacheURLFirst = []byte(`${1}(process.env.` + cacheURLEnv + `||${2})${3}(process.env.` + cacheURLEnv + `||${4})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func envRead(name string) string {
|
||||||
|
return `process\s*\.\s*env\s*(?:\.\s*` + name + `\b|\[\s*` + quoted(name) + `\s*\])`
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoted matches a string literal in any of the three quote characters. RE2 has no
|
||||||
|
// backreferences, so the pairs are spelled out.
|
||||||
|
func quoted(pattern string) string {
|
||||||
|
return "(?:'" + pattern + "'|\"" + pattern + "\"|`" + pattern + "`)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionScriptPaths returns the entrypoints of a node action, the only kind with a bundle. Only
|
||||||
|
// remote actions get here: a local one lives in the user's checkout, which the runner does not
|
||||||
|
// rewrite.
|
||||||
|
func actionScriptPaths(dir string, action *model.Action) []string {
|
||||||
|
if action == nil || !action.Runs.Using.IsNode() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, script := range []string{action.Runs.Pre, action.Runs.Main, action.Runs.Post} {
|
||||||
|
if script == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, script)
|
||||||
|
// `runs` is the action's own yaml, and a key pointing outside its directory is not ours.
|
||||||
|
if rel, err := filepath.Rel(dir, path); err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchActions returns a restore: the tree is shared, so a job with patching off gets no edits.
|
||||||
|
func patchActions(ctx context.Context, scripts []string) func() {
|
||||||
|
restore := map[string][]byte{}
|
||||||
|
for _, script := range scripts {
|
||||||
|
original, err := patchBundle(script)
|
||||||
|
if original != nil {
|
||||||
|
restore[script] = original // also when the write failed part way through
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
for script, original := range restore {
|
||||||
|
if err := os.WriteFile(script, original, 0o644); err != nil { //nolint:gosec // as the checkout wrote it
|
||||||
|
common.Logger(ctx).Warnf("actions toolkit: %s left patched in the shared copy: %v", script, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchBundle returns the bytes it replaced, or nil when it left the bundle alone.
|
||||||
|
func patchBundle(script string) ([]byte, error) {
|
||||||
|
info, err := os.Stat(script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Size() > maxBundleSize {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(script)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
patched, ok := patchedBundle(data)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return data, os.WriteFile(script, patched, info.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
||||||
|
// service at the cache server. A bundle this runner cannot account for comes back untouched.
|
||||||
|
func patchedBundle(data []byte) ([]byte, bool) {
|
||||||
|
// Literals before regex: most bundles carry neither toolkit and stop here. The artifact gate
|
||||||
|
// guards a refusal with no URL to move, so it opens alone; the cache gate opens only with its
|
||||||
|
// service URL, since a bundle whose getter this cannot find is better left on v1.
|
||||||
|
artifact := bytes.Contains(data, []byte(artifactRefusal))
|
||||||
|
cache := bytes.Contains(data, []byte(CacheServiceV2Env)) && serviceURLBranches.Match(data)
|
||||||
|
if !artifact && !cache {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
if !localhostTest.Match(data) {
|
||||||
|
return data, false
|
||||||
|
}
|
||||||
|
|
||||||
|
opened := localhostTest.ReplaceAllFunc(data, func(test []byte) []byte {
|
||||||
|
// Drop the hostname from the test rather than rewriting the call, so the bundle's own
|
||||||
|
// quoting survives and the result stays valid even inside a string literal.
|
||||||
|
return bytes.Replace(test, []byte(localhostHost), nil, 1)
|
||||||
|
})
|
||||||
|
if cache {
|
||||||
|
opened = serviceURLBranches.ReplaceAll(opened, cacheURLFirst)
|
||||||
|
}
|
||||||
|
return opened, true
|
||||||
|
}
|
||||||
@@ -108,17 +108,15 @@ func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[str
|
|||||||
return string(out)
|
return string(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchedAction downloads one entrypoint and patches it exactly as a downloaded action would be,
|
// patchedAction downloads one entrypoint and patches it exactly as a downloaded action would be.
|
||||||
// keeping the untouched original in the sidecar beside it.
|
|
||||||
func patchedAction(t *testing.T, repo, ref, entrypoint string) string {
|
func patchedAction(t *testing.T, repo, ref, entrypoint string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
body, err := os.ReadFile(bundleFromGitHub(t, repo, ref, entrypoint))
|
body, err := os.ReadFile(bundleFromGitHub(t, repo, ref, entrypoint))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
dir := tempDirPath(t)
|
script := filepath.Join(tempDirPath(t), filepath.Base(entrypoint))
|
||||||
script := filepath.Join(dir, filepath.Base(entrypoint))
|
|
||||||
require.NoError(t, os.WriteFile(script, body, 0o600))
|
require.NoError(t, os.WriteFile(script, body, 0o600))
|
||||||
patchToolkit(t.Context(), dir, []string{script})
|
patchActions(t.Context(), []string{script})
|
||||||
return script
|
return script
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +182,7 @@ func TestCacheServiceV2EndToEnd(t *testing.T) {
|
|||||||
// cache server on its own address. That is what a runner without a results service of its own
|
// cache server on its own address. That is what a runner without a results service of its own
|
||||||
// leaves its jobs with, so it has to round trip too.
|
// leaves its jobs with, so it has to round trip too.
|
||||||
env.workspace = tempDirPath(t)
|
env.workspace = tempDirPath(t)
|
||||||
v1 := runActionEntrypoint(t, filepath.Join(sidecarDir(filepath.Dir(restore)), "index.js"), env, inputs)
|
v1 := runActionEntrypoint(t, bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/restore/index.js"), env, inputs)
|
||||||
require.Contains(t, v1, "Cache service version: v1")
|
require.Contains(t, v1, "Cache service version: v1")
|
||||||
require.Contains(t, v1, "Cache restored from key: "+key)
|
require.Contains(t, v1, "Cache restored from key: "+key)
|
||||||
}
|
}
|
||||||
@@ -194,7 +192,7 @@ func TestCacheServiceV2EndToEnd(t *testing.T) {
|
|||||||
// anchored on that distance. One entrypoint from each of the families that bundle the cache
|
// anchored on that distance. One entrypoint from each of the families that bundle the cache
|
||||||
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
|
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
|
||||||
// the two shapes and leaving every cache on v1.
|
// the two shapes and leaving every cache on v1.
|
||||||
func TestToolkitPatchAcrossActions(t *testing.T) {
|
func TestPatchedBundleAcrossActions(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
repo, ref, path string
|
repo, ref, path string
|
||||||
wantPatched bool
|
wantPatched bool
|
||||||
@@ -5,7 +5,6 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,51 +172,42 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
|
|||||||
require.NoError(t, err, "%s", checked)
|
require.NoError(t, err, "%s", checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPatchBundleKeepsTheOriginal(t *testing.T) {
|
// An action with a pre step is copied, and so patched, twice.
|
||||||
dir, script := bundleFile(t, gateTSC)
|
func TestPatchBundleIsIdempotent(t *testing.T) {
|
||||||
original := originalFor(dir, script)
|
script := bundleFile(t, gateTSC)
|
||||||
|
|
||||||
require.NoError(t, patchBundle(script, original))
|
original, err := patchBundle(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, original)
|
||||||
patched, err := os.ReadFile(script)
|
patched, err := os.ReadFile(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, gateOpened(string(patched)))
|
require.True(t, gateOpened(string(patched)))
|
||||||
|
|
||||||
kept, err := os.ReadFile(original)
|
original, err = patchBundle(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, gateTSC, string(kept), "the untouched bundle is kept outside the action tree")
|
assert.Nil(t, original, "a patched bundle is not patched again")
|
||||||
assert.NotContains(t, original, dir+string(filepath.Separator), "originals must not ship into job containers")
|
|
||||||
|
|
||||||
// Patching again must not stack, and must not overwrite the kept original.
|
|
||||||
require.NoError(t, patchBundle(script, original))
|
|
||||||
again, err := os.ReadFile(script)
|
again, err := os.ReadFile(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, string(patched), string(again))
|
assert.Equal(t, string(patched), string(again))
|
||||||
kept, err = os.ReadFile(original)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, gateTSC, string(kept))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A bundle with nothing to patch is left exactly as it was, with no original kept beside it.
|
|
||||||
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
||||||
dir, script := bundleFile(t, `console.log("checkout")`)
|
script := bundleFile(t, `console.log("checkout")`)
|
||||||
original := originalFor(dir, script)
|
|
||||||
|
|
||||||
require.NoError(t, patchBundle(script, original))
|
original, err := patchBundle(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, original)
|
||||||
body, err := os.ReadFile(script)
|
body, err := os.ReadFile(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, `console.log("checkout")`, string(body))
|
assert.Equal(t, `console.log("checkout")`, string(body))
|
||||||
_, err = os.Stat(original)
|
|
||||||
assert.True(t, os.IsNotExist(err), "no original is kept for a bundle that was not patched")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// bundleFile writes one entrypoint into a fresh action directory.
|
func bundleFile(t *testing.T, body string) string {
|
||||||
func bundleFile(t *testing.T, body string) (dir, script string) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
dir = t.TempDir()
|
script := filepath.Join(t.TempDir(), "index.js")
|
||||||
script = filepath.Join(dir, "index.js")
|
|
||||||
require.NoError(t, os.WriteFile(script, []byte(body), 0o600))
|
require.NoError(t, os.WriteFile(script, []byte(body), 0o600))
|
||||||
return dir, script
|
return script
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActionScriptPaths(t *testing.T) {
|
func TestActionScriptPaths(t *testing.T) {
|
||||||
@@ -226,101 +217,53 @@ func TestActionScriptPaths(t *testing.T) {
|
|||||||
// Only a node action has a bundle to patch.
|
// Only a node action has a bundle to patch.
|
||||||
assert.Nil(t, actionScriptPaths("/a", &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "alpine"}}))
|
assert.Nil(t, actionScriptPaths("/a", &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "alpine"}}))
|
||||||
assert.Nil(t, actionScriptPaths("/a", nil))
|
assert.Nil(t, actionScriptPaths("/a", nil))
|
||||||
|
|
||||||
|
// An action naming a file outside its own directory does not get it rewritten.
|
||||||
|
escaping := &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../../elsewhere/index.js"}}
|
||||||
|
assert.Nil(t, actionScriptPaths("/a", escaping))
|
||||||
}
|
}
|
||||||
|
|
||||||
// A step that fails with a patched bundle gets the untouched bundle back, and the action is not
|
// The bundle has to be patched whatever state the shared action directory is in, because a
|
||||||
// patched again, so later jobs run it exactly as its author shipped it.
|
// concurrent job's prepare checks the action out again and resets it.
|
||||||
func TestRevertToolkit(t *testing.T) {
|
func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
||||||
dir, script := bundleFile(t, gateTSC)
|
copiedBundle := func(t *testing.T, noPatch bool) string {
|
||||||
scripts := []string{script}
|
|
||||||
|
|
||||||
patchToolkit(t.Context(), dir, scripts)
|
|
||||||
body, err := os.ReadFile(script)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, gateOpened(string(body)), "precondition: the bundle is patched")
|
|
||||||
|
|
||||||
revertToolkit(t.Context(), dir, scripts)
|
|
||||||
body, err = os.ReadFile(script)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, gateTSC, string(body), "the original bundle is back")
|
|
||||||
|
|
||||||
// The skip marker survives, so the action stays unpatched from now on.
|
|
||||||
patchToolkit(t.Context(), dir, scripts)
|
|
||||||
body, err = os.ReadFile(script)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, gateTSC, string(body), "a reverted action stays unpatched")
|
|
||||||
}
|
|
||||||
|
|
||||||
// An action whose ref moves is checked out over the patched bundle. The kept original then
|
|
||||||
// belongs to the version before the move, and must not be restored over the new one.
|
|
||||||
func TestPatchBundleAfterTheActionMoved(t *testing.T) {
|
|
||||||
dir, script := bundleFile(t, gateTSC)
|
|
||||||
original := originalFor(dir, script)
|
|
||||||
scripts := []string{script}
|
|
||||||
|
|
||||||
patchToolkit(t.Context(), dir, scripts)
|
|
||||||
require.NoError(t, os.WriteFile(script, []byte(gateWebpack), 0o600)) // the new version lands
|
|
||||||
|
|
||||||
// Reverting must not roll the action back to the version the original came from.
|
|
||||||
revertToolkit(t.Context(), dir, scripts)
|
|
||||||
body, err := os.ReadFile(script)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, gateWebpack, string(body))
|
|
||||||
|
|
||||||
// Nothing was reverted, so the action is not marked off either: the new version is patched
|
|
||||||
// in its own right, and keeps its own original.
|
|
||||||
require.NoFileExists(t, filepath.Join(sidecarDir(dir), skipMarker))
|
|
||||||
require.NoError(t, patchBundle(script, original))
|
|
||||||
body, err = os.ReadFile(script)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, gateOpened(string(body)))
|
|
||||||
kept, err := os.ReadFile(original)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, gateWebpack, string(kept))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The wiring: a step patches its own bundles only when the runner serves the v2 API, and a step
|
|
||||||
// that fails gets them back. The action's path inside its repository is part of where they live.
|
|
||||||
func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
|
||||||
newStep := func(t *testing.T, patch bool) (*stepActionRemote, string) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
cm := &containerMock{}
|
||||||
sar := &stepActionRemote{
|
sar := &stepActionRemote{
|
||||||
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
||||||
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
||||||
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
Config: &Config{ActionCacheDir: t.TempDir(), PatchToolkit: patch},
|
Config: &Config{ActionCacheDir: t.TempDir(), NoActionPatch: noPatch},
|
||||||
|
JobContainer: cm,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
||||||
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
||||||
return sar, script
|
|
||||||
|
var copied string
|
||||||
|
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error {
|
||||||
|
body, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
copied = string(body)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir(), "sub", "/var/run/act/actions/repo/sub"))
|
||||||
|
source, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateTSC, string(source), "the shared copy is left as it was found")
|
||||||
|
return copied
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("left alone when the runner does not patch", func(t *testing.T) {
|
t.Run("patched on its way in", func(t *testing.T) {
|
||||||
sar, script := newStep(t, false)
|
assert.True(t, gateOpened(copiedBundle(t, false)))
|
||||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
|
||||||
|
|
||||||
body, err := os.ReadFile(script)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, gateTSC, string(body))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("patched, and put back when the step fails", func(t *testing.T) {
|
// The escape hatch, for an action the edit breaks: the artifact actions refuse again, and the
|
||||||
sar, script := newStep(t, true)
|
// cache client keeps to v1.
|
||||||
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
t.Run("as shipped when the runner is told not to patch", func(t *testing.T) {
|
||||||
|
assert.Equal(t, gateTSC, copiedBundle(t, true))
|
||||||
body, err := os.ReadFile(script)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, gateOpened(string(body)))
|
|
||||||
|
|
||||||
failed := errors.New("the step failed")
|
|
||||||
require.ErrorIs(t, sar.revertToolkitOnFailure(func(context.Context) error { return failed })(t.Context()), failed)
|
|
||||||
|
|
||||||
body, err = os.ReadFile(script)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, gateTSC, string(body))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+53
-148
@@ -5,12 +5,10 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -21,28 +19,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||||
if !rc.Config.NoSkipCheckout {
|
localWorkflow, err := model.ParseReusableWorkflowUses(rc.Run.Job().Uses)
|
||||||
fullPath := rc.Run.Job().Uses
|
if err != nil {
|
||||||
|
return common.NewErrorExecutor(err)
|
||||||
fileName := path.Base(fullPath)
|
|
||||||
workflowDir := strings.TrimSuffix(fullPath, path.Join("/", fileName))
|
|
||||||
workflowDir = strings.TrimPrefix(workflowDir, "./")
|
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
|
||||||
// resolve the local workflow against the workspace root, not the process
|
|
||||||
// working directory, so it is found regardless of where the runner is invoked
|
|
||||||
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ./.gitea/workflows/wf.yml -> .gitea/workflows/wf.yml
|
if !rc.Config.NoSkipCheckout {
|
||||||
trimmedUses := strings.TrimPrefix(rc.Run.Job().Uses, "./")
|
// resolve the local workflow against the workspace root, not the process
|
||||||
// uses string format is {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}
|
// working directory, so it is found regardless of where the runner is invoked
|
||||||
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, trimmedUses, rc.Config.PresetGitHubContext.Sha)
|
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path)
|
||||||
|
}
|
||||||
|
|
||||||
remoteReusableWorkflow := newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses)
|
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
|
||||||
if remoteReusableWorkflow == nil {
|
|
||||||
return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
|
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, uses)
|
||||||
|
if err != nil {
|
||||||
|
return common.NewErrorExecutor(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
||||||
@@ -52,92 +44,46 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||||
uses := rc.Run.Job().Uses
|
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, rc.Run.Job().Uses)
|
||||||
|
if err != nil {
|
||||||
var remoteReusableWorkflow *remoteReusableWorkflow
|
return common.NewErrorExecutor(err)
|
||||||
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
|
|
||||||
remoteReusableWorkflow = newRemoteReusableWorkflowFromAbsoluteURL(uses)
|
|
||||||
if remoteReusableWorkflow == nil {
|
|
||||||
return common.NewErrorExecutor(fmt.Errorf("expected format http(s)://{domain}/{owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
remoteReusableWorkflow = newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses)
|
|
||||||
if remoteReusableWorkflow == nil {
|
|
||||||
return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref}
|
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref}
|
||||||
// instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using
|
// instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using
|
||||||
// multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again
|
// multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again
|
||||||
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Owner, 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(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
//
|
//
|
||||||
// Callers must not change remoteReusableWorkflow.URL, because:
|
// Callers must not change remoteReusableWorkflow.URL, because:
|
||||||
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
|
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
|
||||||
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat
|
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflow
|
||||||
//
|
//
|
||||||
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
||||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
cloneURL = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
||||||
|
}
|
||||||
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
||||||
URL: cloneURL,
|
URL: interpolatedURL,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
Dir: targetDirectory,
|
Dir: targetDirectory,
|
||||||
Token: token,
|
Token: token,
|
||||||
@@ -147,15 +93,12 @@ 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 {
|
||||||
// Scoped to the yaml read so concurrent invocations don't serialize
|
// Serialize workflow reads with cache updates.
|
||||||
// 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 modelNewWorkflowPlanner(path.Join(directory, workflow), true)
|
return model.NewWorkflowPlanner(path.Join(directory, workflow), true)
|
||||||
}()
|
}()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -166,12 +109,11 @@ 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),
|
||||||
@@ -179,7 +121,7 @@ func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) com
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReusableWorkflowRunner(rc *RunContext) (Runner, error) {
|
func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) {
|
||||||
runner := &runnerImpl{
|
runner := &runnerImpl{
|
||||||
config: rc.Config,
|
config: rc.Config,
|
||||||
eventJSON: rc.EventJSON,
|
eventJSON: rc.EventJSON,
|
||||||
@@ -194,77 +136,40 @@ func NewReusableWorkflowRunner(rc *RunContext) (Runner, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type remoteReusableWorkflow struct {
|
type remoteReusableWorkflow struct {
|
||||||
URL string
|
*model.ReusableWorkflowUses
|
||||||
Org string
|
URL string
|
||||||
Repo string
|
|
||||||
Filename string
|
|
||||||
Ref string
|
|
||||||
|
|
||||||
GitPlatform string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *remoteReusableWorkflow) CloneURL() string {
|
func (r *remoteReusableWorkflow) CloneURL() string {
|
||||||
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
|
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
|
||||||
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
|
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
|
||||||
return fmt.Sprintf("%s/%s/%s", r.URL, r.Org, r.Repo)
|
return fmt.Sprintf("%s/%s/%s", r.URL, r.Owner, r.Repo)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Org, r.Repo)
|
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Owner, r.Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *remoteReusableWorkflow) FilePath() string {
|
var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
|
||||||
return fmt.Sprintf("./.%s/workflows/%s", r.GitPlatform, r.Filename)
|
|
||||||
|
// For Gitea
|
||||||
|
// newRemoteReusableWorkflow parses a remote `uses`, an absolute URL up to `{owner}/{repo}/.{git_platform}/workflows/` replaces baseURL
|
||||||
|
func newRemoteReusableWorkflow(baseURL, uses string) (*remoteReusableWorkflow, error) {
|
||||||
|
if matches := absoluteReusableWorkflowURLRegex.FindStringSubmatch(uses); matches != nil {
|
||||||
|
baseURL, uses = matches[1], matches[2]
|
||||||
|
}
|
||||||
|
parsed, err := model.ParseReusableWorkflowUses(uses)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if parsed.IsLocal() {
|
||||||
|
return nil, fmt.Errorf("expected a remote reusable workflow, got %q", uses)
|
||||||
|
}
|
||||||
|
return &remoteReusableWorkflow{ReusableWorkflowUses: parsed, URL: baseURL}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow`
|
func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Executor {
|
||||||
// workflows from `.gitea/workflows` and `.github/workflows` are supported
|
|
||||||
func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow {
|
|
||||||
// GitHub docs:
|
|
||||||
// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses
|
|
||||||
r := regexp.MustCompile(`^([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`)
|
|
||||||
matches := r.FindStringSubmatch(uses)
|
|
||||||
if len(matches) != 6 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &remoteReusableWorkflow{
|
|
||||||
Org: matches[1],
|
|
||||||
Repo: matches[2],
|
|
||||||
GitPlatform: matches[3],
|
|
||||||
Filename: matches[4],
|
|
||||||
Ref: matches[5],
|
|
||||||
URL: url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gitea
|
|
||||||
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow` from an absolute url
|
|
||||||
func newRemoteReusableWorkflowFromAbsoluteURL(uses string) *remoteReusableWorkflow {
|
|
||||||
r := regexp.MustCompile(`^(https?://.*)/([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`)
|
|
||||||
matches := r.FindStringSubmatch(uses)
|
|
||||||
if len(matches) != 7 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &remoteReusableWorkflow{
|
|
||||||
URL: matches[1],
|
|
||||||
Org: matches[2],
|
|
||||||
Repo: matches[3],
|
|
||||||
GitPlatform: matches[4],
|
|
||||||
Filename: matches[5],
|
|
||||||
Ref: matches[6],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gitea
|
|
||||||
func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
caller := runner.caller
|
||||||
|
|
||||||
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
|
||||||
@@ -287,14 +192,14 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult)
|
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, reusedWorkflowJobResult)
|
||||||
} else {
|
} else {
|
||||||
// Serialize this shared Job.Result write against the other matrix combos
|
// Serialize this shared Job.Result write against the other matrix combos
|
||||||
// and setJobResult (same lockJob key).
|
// and setJobResult (same lockJob key).
|
||||||
unlock := lockJob(rc.Run.Job())
|
unlock := lockJob(rc.Run.Job())
|
||||||
rc.result(reusedWorkflowJobResult)
|
rc.result(reusedWorkflowJobResult)
|
||||||
unlock()
|
unlock()
|
||||||
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
common.Logger(ctx).WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -77,19 +77,11 @@ 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{}}},
|
||||||
@@ -100,26 +92,34 @@ 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 before planner was reached: %v", err)
|
t.Fatalf("executor returned while clone lock was held: %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 planner ran")
|
t.Fatal("executor did not return after lock was released")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) {
|
||||||
|
workdir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644))
|
||||||
|
|
||||||
|
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{Workdir: workdir},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
|
||||||
|
}
|
||||||
|
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotErrorIs(t, err, fs.ErrNotExist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+411
-364
File diff suppressed because it is too large.
Load diff
+636
-253
File diff suppressed because it is too large.
Load diff
+127
-141
@@ -6,9 +6,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -16,67 +14,55 @@ 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.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Runner provides capabilities to run GitHub actions
|
|
||||||
type Runner interface {
|
|
||||||
NewPlanExecutor(plan *model.Plan) common.Executor
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config contains the config for a new runner
|
// Config contains the config for a new runner
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Actor string // the user that triggered the event
|
Actor string // the user that triggered the event
|
||||||
Workdir string // path to working directory
|
Workdir string // path to working directory
|
||||||
ActionCacheDir string // path used for caching action contents
|
ActionCacheDir string // path used for caching action contents
|
||||||
ActionOfflineMode bool // when offline, use cached action contents
|
ActionOfflineMode bool // when offline, use cached action contents
|
||||||
ActionCloneDepth int // limit history when cloning an action repo; 0 clones every branch in full
|
ActionCloneDepth int // limit history when cloning an action repo, 0 clones every branch in full
|
||||||
BindWorkdir bool // bind the workdir to the job container
|
BindWorkdir bool // bind the workdir to the job container
|
||||||
EventName string // name of event to run
|
EventName string // name of event to run
|
||||||
EventPath string // path to JSON file to use for event.json in containers
|
EventPath string // path to JSON file to use for event.json in containers
|
||||||
DefaultBranch string // name of the main branch for this repository
|
ForcePull bool // force pulling of the image, even if already present
|
||||||
ReuseContainers bool // reuse containers to maintain state
|
ForceRebuild bool // force rebuilding local docker image action
|
||||||
ForcePull bool // force pulling of the image, even if already present
|
JSONLogger bool // use json or text logger
|
||||||
ForceRebuild bool // force rebuilding local docker image action
|
Env map[string]string // env for containers
|
||||||
LogOutput bool // log the output from docker run
|
Secrets map[string]string // list of secrets
|
||||||
JSONLogger bool // use json or text logger
|
ExtraMasks []string // values to hide that are not in Secrets, such as the proxy password
|
||||||
LogPrefixJobID bool // switches from the full job name to the job id
|
Vars map[string]string // list of vars
|
||||||
Env map[string]string // env for containers
|
Token string // GitHub token
|
||||||
Inputs map[string]string // manually passed action inputs
|
InsecureSecrets bool // switch hiding output when printing to terminal
|
||||||
Secrets map[string]string // list of secrets
|
Privileged bool // use privileged mode
|
||||||
Vars map[string]string // list of vars
|
UsernsMode string // user namespace to use
|
||||||
Token string // GitHub token
|
ContainerArchitecture string // Desired OS/architecture platform for running containers
|
||||||
InsecureSecrets bool // switch hiding output when printing to terminal
|
ContainerDaemonSocket string // Path to Docker daemon socket
|
||||||
Platforms map[string]string // list of platforms
|
ContainerOptions string // Options for the job container
|
||||||
Privileged bool // use privileged mode
|
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
|
||||||
UsernsMode string // user namespace to use
|
GitHubInstance string // GitHub instance to use, default "github.com"
|
||||||
ContainerArchitecture string // Desired OS/architecture platform for running containers
|
ContainerCapAdd []string // list of kernel capabilities to add to the containers
|
||||||
ContainerDaemonSocket string // Path to Docker daemon socket
|
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
|
||||||
ContainerOptions string // Options for the job container
|
ArtifactServerPath string // the path where the artifact server stores uploads
|
||||||
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
|
ArtifactServerAddr string // the address the artifact server binds to
|
||||||
GitHubInstance string // GitHub instance to use, default "github.com"
|
ArtifactServerPort string // the port the artifact server binds to
|
||||||
ContainerCapAdd []string // list of kernel capabilities to add to the containers
|
NoSkipCheckout bool // do not skip actions/checkout
|
||||||
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
|
DisableActEnv bool // do not inject the ACT=true environment variable into jobs
|
||||||
AutoRemove bool // controls if the container is automatically removed upon workflow completion
|
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
||||||
ArtifactServerPath string // the path where the artifact server stores uploads
|
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
||||||
ArtifactServerAddr string // the address the artifact server binds to
|
CacheContainer string // let jobs reach an otherwise isolated cache
|
||||||
ArtifactServerPort string // the port the artifact server binds to
|
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
||||||
NoSkipCheckout bool // do not skip actions/checkout
|
NoActionPatch bool // run actions exactly as published, applying no compatibility patches, see patch_actions.go
|
||||||
DisableActEnv bool // do not inject the ACT=true environment variable into jobs
|
|
||||||
RemoteName string // remote name in local git repo config
|
|
||||||
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
|
|
||||||
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
|
|
||||||
Matrix map[string]map[string]bool // Matrix config to run
|
|
||||||
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
|
||||||
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
|
||||||
ActionCache ActionCache // Use a custom ActionCache Implementation
|
|
||||||
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
|
||||||
PatchToolkit bool // edit the @actions toolkit bundled into an action so it works against Gitea, see toolkit_patch.go
|
|
||||||
|
|
||||||
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
PresetGitHubContext *model.GithubContext // overrides actor, ref, repository, token and related context fields
|
||||||
|
WorkflowCallInputs map[string]any // the `inputs` Gitea resolved for a called workflow's job, nil for other jobs
|
||||||
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
||||||
ContainerNamePrefix string // the prefix of container name
|
ContainerNamePrefix string // the prefix of container name
|
||||||
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
||||||
@@ -88,17 +74,17 @@ type Config struct {
|
|||||||
// differ from GitHubInstance when the runner registered with a different hostname than
|
// differ from GitHubInstance when the runner registered with a different hostname than
|
||||||
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
|
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
|
||||||
DefaultActionInstanceIsSelfHosted bool
|
DefaultActionInstanceIsSelfHosted bool
|
||||||
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
PlatformPicker func(labels []string) string
|
||||||
JobLoggerLevel *log.Level // the level of job logger
|
JobLoggerLevel *log.Level // the level of job logger
|
||||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||||
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
||||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||||
@@ -141,8 +127,10 @@ type runnerImpl struct {
|
|||||||
caller *caller // the job calling this runner (caller of a reusable workflow)
|
caller *caller // the job calling this runner (caller of a reusable workflow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Runner = runnerImpl
|
||||||
|
|
||||||
// New Creates a new Runner
|
// New Creates a new Runner
|
||||||
func New(runnerConfig *Config) (Runner, error) {
|
func New(runnerConfig *Config) (*Runner, error) {
|
||||||
runner := &runnerImpl{
|
runner := &runnerImpl{
|
||||||
config: runnerConfig,
|
config: runnerConfig,
|
||||||
}
|
}
|
||||||
@@ -150,7 +138,7 @@ func New(runnerConfig *Config) (Runner, error) {
|
|||||||
return runner.configure()
|
return runner.configure()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (runner *runnerImpl) configure() (Runner, error) {
|
func (runner *runnerImpl) configure() (*runnerImpl, error) {
|
||||||
if runner.config.RunnerName == "" {
|
if runner.config.RunnerName == "" {
|
||||||
// Callers that do not register, such as `exec`, still get a `runner.name`.
|
// Callers that do not register, such as `exec`, still get a `runner.name`.
|
||||||
runner.config.RunnerName, _ = os.Hostname()
|
runner.config.RunnerName, _ = os.Hostname()
|
||||||
@@ -166,19 +154,22 @@ func (runner *runnerImpl) configure() (Runner, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runner.eventJSON = string(eventJSONBytes)
|
runner.eventJSON = string(eventJSONBytes)
|
||||||
} else if len(runner.config.Inputs) != 0 {
|
|
||||||
eventMap := map[string]map[string]string{
|
|
||||||
"inputs": runner.config.Inputs,
|
|
||||||
}
|
|
||||||
eventJSON, err := json.Marshal(eventMap)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runner.eventJSON = string(eventJSON)
|
|
||||||
}
|
}
|
||||||
return runner, nil
|
return runner, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maxParallelFor(strategy *model.Strategy, combinations int) int {
|
||||||
|
maxParallel := 4 // actionslib has no default for an undeclared max-parallel
|
||||||
|
if strategy != nil {
|
||||||
|
if limit, err := strategy.MaxParallel(); err != nil {
|
||||||
|
log.Errorf("Ignoring invalid max-parallel: %v", err)
|
||||||
|
} else if limit > 0 {
|
||||||
|
maxParallel = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return min(maxParallel, combinations)
|
||||||
|
}
|
||||||
|
|
||||||
// NewPlanExecutor ...
|
// NewPlanExecutor ...
|
||||||
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||||
maxJobNameLen := 0
|
maxJobNameLen := 0
|
||||||
@@ -213,53 +204,58 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
log.Debugf("Job.Outputs: %v", job.Outputs)
|
log.Debugf("Job.Outputs: %v", job.Outputs)
|
||||||
log.Debugf("Job.Uses: %v", job.Uses)
|
log.Debugf("Job.Uses: %v", job.Uses)
|
||||||
log.Debugf("Job.With: %v", job.With)
|
log.Debugf("Job.With: %v", job.With)
|
||||||
// log.Debugf("Job.RawSecrets: %v", job.RawSecrets)
|
|
||||||
log.Debugf("Job.Result: %v", job.Result)
|
log.Debugf("Job.Result: %v", job.Result)
|
||||||
|
|
||||||
if job.Strategy != nil {
|
if job.Strategy != nil || job.RawStrategy.Kind == yaml.ScalarNode {
|
||||||
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.FailFast)
|
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
||||||
log.Debugf("Job.Strategy.MaxParallel: %v", job.Strategy.MaxParallel)
|
if err != nil {
|
||||||
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
return err
|
||||||
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
}
|
||||||
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
if job.RawStrategy.Kind == yaml.ScalarNode {
|
||||||
|
if err := model.DecodeEvaluated("job strategy", job.RawStrategy, strategyRc.ExprEval.shared(ctx).EvaluateYamlNode, &job.Strategy); err != nil {
|
||||||
strategyRc := runner.newRunContext(ctx, run, nil)
|
return err
|
||||||
// Resolve template expressions in the matrix node before Matrix() is called.
|
}
|
||||||
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
|
} else {
|
||||||
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.GetFailFast())
|
||||||
log.Errorf("Error while evaluating matrix: %v", err)
|
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
||||||
|
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||||
|
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||||
|
// An unevaluated expression is left in place, which GetMatrixes below rejects.
|
||||||
|
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
||||||
|
log.Errorf("Error while evaluating matrix: %v", err)
|
||||||
|
}
|
||||||
|
for _, value := range []*string{&job.Strategy.FailFastString, &job.Strategy.MaxParallelString} {
|
||||||
|
if evaluated, err := strategyRc.ExprEval.Interpolate(ctx, *value); err == nil {
|
||||||
|
*value = evaluated
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var matrixes []map[string]any
|
matrixes, err := job.GetMatrixes()
|
||||||
if m, err := job.GetMatrixes(); err != nil {
|
if err != nil {
|
||||||
log.Errorf("Error while get job's matrix: %v", err)
|
return fmt.Errorf("could not get job matrix: %w", err)
|
||||||
} else {
|
|
||||||
log.Debugf("Job Matrices: %v", m)
|
|
||||||
log.Debugf("Runner Matrices: %v", runner.config.Matrix)
|
|
||||||
matrixes = selectMatrixes(m, runner.config.Matrix)
|
|
||||||
}
|
}
|
||||||
log.Debugf("Final matrix after applying user inclusions '%v'", matrixes)
|
log.Debugf("Job Matrices: %v", matrixes)
|
||||||
|
|
||||||
maxParallel := 4
|
maxParallel := maxParallelFor(job.Strategy, len(matrixes))
|
||||||
if job.Strategy != nil {
|
|
||||||
// Ensure GetMaxParallel() is called if MaxParallel is still 0
|
|
||||||
if job.Strategy.MaxParallel == 0 {
|
|
||||||
job.Strategy.MaxParallel = job.Strategy.GetMaxParallel()
|
|
||||||
}
|
|
||||||
maxParallel = job.Strategy.MaxParallel
|
|
||||||
log.Debugf("Using job.Strategy.MaxParallel: %d", maxParallel)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(matrixes) < maxParallel {
|
|
||||||
log.Debugf("Adjusting maxParallel from %d to %d (number of matrix combinations)", maxParallel, len(matrixes))
|
|
||||||
maxParallel = len(matrixes)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
||||||
|
|
||||||
for i, matrix := range matrixes {
|
for i, matrix := range matrixes {
|
||||||
rc := runner.newRunContext(ctx, run, matrix)
|
index, total := i, len(matrixes)
|
||||||
|
if runner.caller == nil && runner.config.PresetGitHubContext != nil && len(matrix) > 0 {
|
||||||
|
var expanded struct {
|
||||||
|
Index int `yaml:"job-index"`
|
||||||
|
Total int `yaml:"job-total"`
|
||||||
|
}
|
||||||
|
_ = job.RawStrategy.Decode(&expanded) // Gitea expanded the combination, a version writing neither leaves them unknown
|
||||||
|
index, total = expanded.Index, expanded.Total
|
||||||
|
}
|
||||||
|
rc, err := runner.newCombinationRunContext(ctx, run, matrix, index, total)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
rc.JobName = rc.Name
|
rc.JobName = rc.Name
|
||||||
if len(matrixes) > 1 {
|
if len(matrixes) > 1 {
|
||||||
rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1)
|
rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1)
|
||||||
@@ -268,7 +264,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
maxJobNameLen = len(rc.String())
|
maxJobNameLen = len(rc.String())
|
||||||
}
|
}
|
||||||
if rc.caller != nil { // For Gitea
|
if rc.caller != nil { // For Gitea
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, "pending")
|
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "pending")
|
||||||
}
|
}
|
||||||
stageExecutor = append(stageExecutor, func(ctx context.Context) error {
|
stageExecutor = append(stageExecutor, func(ctx context.Context) error {
|
||||||
jobName := fmt.Sprintf("%-*s", maxJobNameLen, rc.String())
|
jobName := fmt.Sprintf("%-*s", maxJobNameLen, rc.String())
|
||||||
@@ -328,7 +324,8 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
|
executor := common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
|
||||||
|
return func(ctx context.Context) error { return executor(ghcontext.WithPinnedCheckout(ctx)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleFailure(plan *model.Plan) common.Executor {
|
func handleFailure(plan *model.Plan) common.Executor {
|
||||||
@@ -336,7 +333,7 @@ func handleFailure(plan *model.Plan) common.Executor {
|
|||||||
for _, stage := range plan.Stages {
|
for _, stage := range plan.Stages {
|
||||||
for _, run := range stage.Runs {
|
for _, run := range stage.Runs {
|
||||||
if run.Job().Result == "failure" && !run.Job().ContinueOnError {
|
if run.Job().Result == "failure" && !run.Job().ContinueOnError {
|
||||||
return fmt.Errorf("Job '%s' failed", run.String())
|
return fmt.Errorf("job '%s' failed", run.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,26 +341,11 @@ func handleFailure(plan *model.Plan) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectMatrixes(originalMatrixes []map[string]any, targetMatrixValues map[string]map[string]bool) []map[string]any {
|
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) {
|
||||||
matrixes := make([]map[string]any, 0)
|
return runner.newCombinationRunContext(ctx, run, matrix, 0, 1)
|
||||||
for _, original := range originalMatrixes {
|
|
||||||
flag := true
|
|
||||||
for key, val := range original {
|
|
||||||
if allowedVals, ok := targetMatrixValues[key]; ok {
|
|
||||||
valToString := fmt.Sprintf("%v", val)
|
|
||||||
if _, ok := allowedVals[valToString]; !ok {
|
|
||||||
flag = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if flag {
|
|
||||||
matrixes = append(matrixes, original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matrixes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) *RunContext {
|
func (runner *runnerImpl) newCombinationRunContext(ctx context.Context, run *model.Run, matrix map[string]any, index, total int) (*RunContext, error) {
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: runner.config,
|
Config: runner.config,
|
||||||
Run: run,
|
Run: run,
|
||||||
@@ -371,21 +353,25 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
|
|||||||
StepResults: make(map[string]*model.StepResult),
|
StepResults: make(map[string]*model.StepResult),
|
||||||
Matrix: matrix,
|
Matrix: matrix,
|
||||||
caller: runner.caller,
|
caller: runner.caller,
|
||||||
|
jobIndex: index,
|
||||||
|
jobTotal: total,
|
||||||
|
}
|
||||||
|
if err := rc.resolveWorkflowCall(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
|
rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String()))
|
||||||
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
|
if job := run.Job(); job != nil && job.RawOutputs.Kind != 0 {
|
||||||
// rewrites the shared Job.Outputs (see interpolateOutputs).
|
job.Outputs = nil // a job no combination ran for reads "" as on GitHub, a Gitea needs placeholder has no RawOutputs and keeps its outputs
|
||||||
if job := run.Job(); job != nil {
|
|
||||||
rc.outputTemplate = maps.Clone(job.Outputs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc
|
return rc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
func (c *caller) setReusedWorkflowJobResult(jobName, result string) {
|
// Keyed by job id, not name: only the values are read, and masking can collapse two names into one.
|
||||||
|
func (c *caller) setReusedWorkflowJobResult(jobID, result string) {
|
||||||
c.updateResultLock.Lock()
|
c.updateResultLock.Lock()
|
||||||
defer c.updateResultLock.Unlock()
|
defer c.updateResultLock.Unlock()
|
||||||
c.reusedWorkflowJobResults[jobName] = result
|
c.reusedWorkflowJobResults[jobID] = result
|
||||||
}
|
}
|
||||||
+56
-185
@@ -7,13 +7,11 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,7 +22,6 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -33,8 +30,20 @@ var (
|
|||||||
logLevel = log.DebugLevel
|
logLevel = log.DebugLevel
|
||||||
workdir = "testdata"
|
workdir = "testdata"
|
||||||
secrets map[string]string
|
secrets map[string]string
|
||||||
|
testRunID = strings.ToLower(rand.Text()[:8]) // keeps docker names apart from concurrent test processes on one daemon
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func mapPlatformPicker(platforms map[string]string) func([]string) string {
|
||||||
|
return func(labels []string) string {
|
||||||
|
for _, label := range labels {
|
||||||
|
if image := platforms[strings.ToLower(label)]; image != "" {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if p := os.Getenv("ACT_TEST_IMAGE"); p != "" {
|
if p := os.Getenv("ACT_TEST_IMAGE"); p != "" {
|
||||||
baseImage = p
|
baseImage = p
|
||||||
@@ -162,10 +171,24 @@ func TestGraphEvent(t *testing.T) {
|
|||||||
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.NotNil(t, plan)
|
assert.NotNil(t, plan)
|
||||||
assert.Empty(t, plan.Stages)
|
assert.Empty(t, plan.Stages)
|
||||||
}
|
|
||||||
|
|
||||||
// these two build the same action Dockerfiles into one image tag, so they cannot overlap
|
for _, workflowPath := range []string{
|
||||||
var sharedImageWorkflows = []string{"local-action-dockerfile", "local-action-via-composite-dockerfile"}
|
"testdata/workflow_dispatch_no_inputs_mapping/workflow_dispatch.yml",
|
||||||
|
"testdata/workflow_dispatch-scalar/workflow_dispatch.yml",
|
||||||
|
} {
|
||||||
|
planner, err := model.NewWorkflowPlanner(workflowPath, true)
|
||||||
|
if !assert.NoError(t, err, workflowPath) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plan, err := planner.PlanEvent("workflow_dispatch")
|
||||||
|
if !assert.NoError(t, err, workflowPath) || !assert.NotNil(t, plan, workflowPath) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if assert.Len(t, plan.Stages, 1, workflowPath) {
|
||||||
|
assert.Len(t, plan.Stages[0].Runs, 1, workflowPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// bounds concurrent plans: each job holds a network, and the daemon's address pool is finite
|
// bounds concurrent plans: each job holds a network, and the daemon's address pool is finite
|
||||||
var planSlots = make(chan struct{}, 4)
|
var planSlots = make(chan struct{}, 4)
|
||||||
@@ -189,29 +212,22 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
|
|
||||||
fullWorkflowPath := filepath.Join(workdir, j.workflowPath)
|
fullWorkflowPath := filepath.Join(workdir, j.workflowPath)
|
||||||
runnerConfig := &Config{
|
runnerConfig := &Config{
|
||||||
Workdir: workdir,
|
Workdir: workdir,
|
||||||
BindWorkdir: false,
|
BindWorkdir: false,
|
||||||
EventName: j.eventName,
|
EventName: j.eventName,
|
||||||
EventPath: cfg.EventPath,
|
EventPath: cfg.EventPath,
|
||||||
Platforms: j.platforms,
|
PlatformPicker: mapPlatformPicker(j.platforms),
|
||||||
// fixtures reuse workflow and job names, so parallel tests would collide without this
|
// fixtures reuse workflow and job names, so parallel tests would collide without this
|
||||||
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-"),
|
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-") + "-" + testRunID,
|
||||||
ReuseContainers: false,
|
|
||||||
// as the shipped runner does, else a fixture asserting a job failure keeps its
|
|
||||||
// container, and its network, on the daemon forever
|
|
||||||
AutoRemove: true,
|
|
||||||
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
||||||
MaxParallel: 2,
|
MaxParallel: 2,
|
||||||
ForceRebuild: true,
|
ForceRebuild: true,
|
||||||
Env: cfg.Env,
|
Env: cfg.Env,
|
||||||
Secrets: cfg.Secrets,
|
Secrets: cfg.Secrets,
|
||||||
Inputs: cfg.Inputs,
|
|
||||||
GitHubInstance: "github.com",
|
GitHubInstance: "github.com",
|
||||||
DefaultActionInstance: cfg.DefaultActionInstance,
|
DefaultActionInstance: cfg.DefaultActionInstance,
|
||||||
ContainerArchitecture: cfg.ContainerArchitecture,
|
ContainerArchitecture: cfg.ContainerArchitecture,
|
||||||
ContainerMaxLifetime: time.Hour,
|
ContainerMaxLifetime: time.Hour,
|
||||||
Matrix: cfg.Matrix,
|
|
||||||
ActionCache: cfg.ActionCache,
|
|
||||||
ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes)
|
ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,11 +240,18 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
plan, err := planner.PlanEvent(j.eventName)
|
plan, err := planner.PlanEvent(j.eventName)
|
||||||
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
if err == nil && plan != nil {
|
if err == nil && plan != nil {
|
||||||
err = func() error {
|
usesDocker := false
|
||||||
|
for _, platform := range j.platforms {
|
||||||
|
if platform != "-self-hosted" {
|
||||||
|
usesDocker = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usesDocker && !common.Dryrun(ctx) {
|
||||||
planSlots <- struct{}{}
|
planSlots <- struct{}{}
|
||||||
defer func() { <-planSlots }()
|
defer func() { <-planSlots }()
|
||||||
return runner.NewPlanExecutor(plan)(ctx)
|
}
|
||||||
}()
|
err = runner.NewPlanExecutor(plan)(ctx)
|
||||||
if j.errorMessage == "" {
|
if j.errorMessage == "" {
|
||||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
} else {
|
} else {
|
||||||
@@ -239,10 +262,6 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
type TestConfig struct {
|
|
||||||
LocalRepositories map[string]string `yaml:"local-repositories"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunEvent(t *testing.T) {
|
func TestRunEvent(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -265,6 +284,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "uses-composite", "push", "", platforms, secrets},
|
{workdir, "uses-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
|
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
|
||||||
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||||
|
{workdir, "uses-step-if-inputs-not-leaked", "push", "", platforms, secrets},
|
||||||
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// Eval
|
// Eval
|
||||||
@@ -276,9 +296,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
|
|
||||||
{workdir, "basic", "push", "", platforms, secrets},
|
{workdir, "basic", "push", "", platforms, secrets},
|
||||||
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
||||||
{workdir, "checkout", "push", "", platforms, secrets},
|
|
||||||
{workdir, "job-container", "push", "", platforms, secrets},
|
{workdir, "job-container", "push", "", platforms, secrets},
|
||||||
{workdir, "job-container-non-root", "push", "", platforms, secrets},
|
|
||||||
{workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets},
|
{workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets},
|
||||||
{workdir, "container-hostname", "push", "", platforms, secrets},
|
{workdir, "container-hostname", "push", "", platforms, secrets},
|
||||||
{workdir, "matrix", "push", "", platforms, secrets},
|
{workdir, "matrix", "push", "", platforms, secrets},
|
||||||
@@ -288,17 +306,14 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "defaults-run", "push", "", platforms, secrets},
|
{workdir, "defaults-run", "push", "", platforms, secrets},
|
||||||
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
|
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
|
||||||
{workdir, "issue-597", "push", "", platforms, secrets},
|
{workdir, "issue-597", "push", "", platforms, secrets},
|
||||||
{workdir, "issue-598", "push", "", platforms, secrets},
|
|
||||||
{workdir, "if-env-act", "push", "", platforms, secrets},
|
{workdir, "if-env-act", "push", "", platforms, secrets},
|
||||||
{workdir, "env-and-path", "push", "", platforms, secrets},
|
|
||||||
{workdir, "environment-files", "push", "", platforms, secrets},
|
{workdir, "environment-files", "push", "", platforms, secrets},
|
||||||
{workdir, "GITHUB_STATE", "push", "", platforms, secrets},
|
{workdir, "GITHUB_STATE", "push", "", platforms, secrets},
|
||||||
{workdir, "environment-files-parser-bug", "push", "", platforms, secrets},
|
{workdir, "environment-files-parser-bug", "push", "", platforms, secrets},
|
||||||
{workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets},
|
{workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets},
|
||||||
{workdir, "outputs", "push", "", platforms, secrets},
|
{workdir, "outputs", "push", "", platforms, secrets},
|
||||||
{workdir, "networking", "push", "", platforms, secrets},
|
{workdir, "networking", "push", "", platforms, secrets},
|
||||||
{workdir, "steps-context/conclusion", "push", "", platforms, secrets},
|
{workdir, "steps-context", "push", "", platforms, secrets},
|
||||||
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
|
|
||||||
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
||||||
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
||||||
{workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets},
|
{workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets},
|
||||||
@@ -308,8 +323,6 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
|
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
|
||||||
{workdir, "workflow_call_inputs", "workflow_call", "", platforms, secrets},
|
{workdir, "workflow_call_inputs", "workflow_call", "", platforms, secrets},
|
||||||
{workdir, "workflow_dispatch", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "workflow_dispatch_no_inputs_mapping", "workflow_dispatch", "", platforms, secrets},
|
|
||||||
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
|
||||||
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
||||||
{workdir, "container-volumes", "push", "", platforms, secrets},
|
{workdir, "container-volumes", "push", "", platforms, secrets},
|
||||||
@@ -323,9 +336,6 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "services", "push", "", platforms, secrets},
|
{workdir, "services", "push", "", platforms, secrets},
|
||||||
{workdir, "services-with-container", "push", "", platforms, secrets},
|
{workdir, "services-with-container", "push", "", platforms, secrets},
|
||||||
{workdir, "services-empty-image", "push", "", platforms, secrets},
|
{workdir, "services-empty-image", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// local remote action overrides
|
|
||||||
{workdir, "local-remote-action-overrides", "push", "", platforms, secrets},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
@@ -334,12 +344,11 @@ func TestRunEvent(t *testing.T) {
|
|||||||
// host /proc bind mounts are Linux-Docker-only
|
// host /proc bind mounts are Linux-Docker-only
|
||||||
requireLinuxDocker(t)
|
requireLinuxDocker(t)
|
||||||
}
|
}
|
||||||
if !slices.Contains(sharedImageWorkflows, table.workflowPath) {
|
t.Parallel()
|
||||||
t.Parallel()
|
|
||||||
}
|
|
||||||
|
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Secrets: table.secrets,
|
Secrets: table.secrets,
|
||||||
|
Env: map[string]string{"GITHUB_REPOSITORY": t.Name() + "-" + testRunID},
|
||||||
}
|
}
|
||||||
|
|
||||||
eventFile := filepath.Join(workdir, table.workflowPath, "event.json")
|
eventFile := filepath.Join(workdir, table.workflowPath, "event.json")
|
||||||
@@ -347,22 +356,6 @@ func TestRunEvent(t *testing.T) {
|
|||||||
config.EventPath = eventFile
|
config.EventPath = eventFile
|
||||||
}
|
}
|
||||||
|
|
||||||
testConfigFile := filepath.Join(workdir, table.workflowPath, "config.yml")
|
|
||||||
if file, err := os.ReadFile(testConfigFile); err == nil {
|
|
||||||
testConfig := &TestConfig{}
|
|
||||||
if yaml.Unmarshal(file, testConfig) == nil {
|
|
||||||
if testConfig.LocalRepositories != nil {
|
|
||||||
config.ActionCache = &LocalRepositoryCache{
|
|
||||||
Parent: GoGitActionCache{
|
|
||||||
path.Clean(path.Join(workdir, "cache")),
|
|
||||||
},
|
|
||||||
LocalRepositories: testConfig.LocalRepositories,
|
|
||||||
CacheDirCache: map[string]string{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
table.runTest(ctx, t, config)
|
table.runTest(ctx, t, config)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -407,20 +400,16 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
||||||
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
|
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
|
||||||
|
|
||||||
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
|
||||||
{workdir, "checkout", "push", "", platforms, secrets},
|
|
||||||
{workdir, "matrix", "push", "", platforms, secrets},
|
{workdir, "matrix", "push", "", platforms, secrets},
|
||||||
{workdir, "commands", "push", "", platforms, secrets},
|
{workdir, "commands", "push", "", platforms, secrets},
|
||||||
{workdir, "defaults-run", "push", "", platforms, secrets},
|
{workdir, "defaults-run", "push", "", platforms, secrets},
|
||||||
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
|
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
|
||||||
{workdir, "issue-597", "push", "", platforms, secrets},
|
{workdir, "issue-597", "push", "", platforms, secrets},
|
||||||
{workdir, "issue-598", "push", "", platforms, secrets},
|
|
||||||
{workdir, "if-env-act", "push", "", platforms, secrets},
|
{workdir, "if-env-act", "push", "", platforms, secrets},
|
||||||
{workdir, "env-and-path", "push", "", platforms, secrets},
|
{workdir, "env-and-path", "push", "", platforms, secrets},
|
||||||
{workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets},
|
{workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets},
|
||||||
{workdir, "outputs", "push", "", platforms, secrets},
|
{workdir, "outputs", "push", "", platforms, secrets},
|
||||||
{workdir, "steps-context/conclusion", "push", "", platforms, secrets},
|
{workdir, "steps-context", "push", "", platforms, secrets},
|
||||||
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
|
|
||||||
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
||||||
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
||||||
{workdir, "evalenv", "push", "", platforms, secrets},
|
{workdir, "evalenv", "push", "", platforms, secrets},
|
||||||
@@ -445,6 +434,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
|
|
||||||
tables = append(tables, []TestJobFileInfo{
|
tables = append(tables, []TestJobFileInfo{
|
||||||
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
||||||
|
{workdir, "builtin-checkout", "push", "", platforms, secrets},
|
||||||
{workdir, "inputs-via-env-context", "push", "", platforms, secrets},
|
{workdir, "inputs-via-env-context", "push", "", platforms, secrets},
|
||||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
||||||
@@ -453,6 +443,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
}...)
|
}...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hostPlanSlots := make(chan struct{}, 2)
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
t.Run(table.workflowPath, func(t *testing.T) {
|
||||||
switch table.workflowPath {
|
switch table.workflowPath {
|
||||||
@@ -461,6 +452,9 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
case "nix-prepend-path":
|
case "nix-prepend-path":
|
||||||
requireHostTools(t, "nix")
|
requireHostTools(t, "nix")
|
||||||
}
|
}
|
||||||
|
t.Parallel()
|
||||||
|
hostPlanSlots <- struct{}{}
|
||||||
|
defer func() { <-hostPlanSlots }()
|
||||||
table.runTest(ctx, t, &Config{})
|
table.runTest(ctx, t, &Config{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -503,47 +497,6 @@ func TestReusableWorkflowCaller(t *testing.T) {
|
|||||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
||||||
}
|
}
|
||||||
|
|
||||||
type maskJobLoggerFactory struct {
|
|
||||||
Output bytes.Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *maskJobLoggerFactory) WithJobLogger() *log.Logger {
|
|
||||||
logger := log.New()
|
|
||||||
logger.SetOutput(io.MultiWriter(&f.Output, os.Stdout))
|
|
||||||
logger.SetLevel(log.DebugLevel)
|
|
||||||
return logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaskValues(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act
|
|
||||||
found := strings.Contains(text, "composite secret")
|
|
||||||
if found {
|
|
||||||
fmt.Printf("\nFound Secret in the given text:\n%s\n", text) //nolint:forbidigo // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
|
|
||||||
requireDocker(t)
|
|
||||||
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
|
||||||
workdir: workdir,
|
|
||||||
workflowPath: "mask-values",
|
|
||||||
eventName: "push",
|
|
||||||
errorMessage: "",
|
|
||||||
platforms: platforms,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger := &maskJobLoggerFactory{}
|
|
||||||
tjfi.runTest(WithJobLoggerFactory(common.WithLogger(context.Background(), logger.WithJobLogger()), logger), t, &Config{})
|
|
||||||
output := logger.Output.String()
|
|
||||||
|
|
||||||
assertNoSecret(output, "secret value")
|
|
||||||
assertNoSecret(output, "YWJjCg==")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunEventSecrets(t *testing.T) {
|
func TestRunEventSecrets(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -565,62 +518,6 @@ func TestRunEventSecrets(t *testing.T) {
|
|||||||
tjfi.runTest(context.Background(), t, &Config{Secrets: secrets, Env: env})
|
tjfi.runTest(context.Background(), t, &Config{Secrets: secrets, Env: env})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunWithService(t *testing.T) {
|
|
||||||
requireDocker(t)
|
|
||||||
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
platforms := map[string]string{
|
|
||||||
"ubuntu-latest": "node:24-bookworm-slim",
|
|
||||||
}
|
|
||||||
|
|
||||||
workflowPath := "services"
|
|
||||||
eventName := "push"
|
|
||||||
|
|
||||||
workdir, err := filepath.Abs("testdata")
|
|
||||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
runnerConfig := &Config{
|
|
||||||
Workdir: workdir,
|
|
||||||
EventName: eventName,
|
|
||||||
Platforms: platforms,
|
|
||||||
ReuseContainers: false,
|
|
||||||
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
|
|
||||||
}
|
|
||||||
runner, err := New(runnerConfig)
|
|
||||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
planner, err := model.NewWorkflowPlanner("testdata/"+workflowPath, true)
|
|
||||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
plan, err := planner.PlanEvent(eventName)
|
|
||||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
err = runner.NewPlanExecutor(plan)(ctx)
|
|
||||||
assert.NoError(t, err, workflowPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunActionInputs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
requireDocker(t)
|
|
||||||
workflowPath := "input-from-cli"
|
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
|
||||||
workdir: workdir,
|
|
||||||
workflowPath: workflowPath,
|
|
||||||
eventName: "workflow_dispatch",
|
|
||||||
errorMessage: "",
|
|
||||||
platforms: platforms,
|
|
||||||
}
|
|
||||||
|
|
||||||
inputs := map[string]string{
|
|
||||||
"SOME_INPUT": "input",
|
|
||||||
}
|
|
||||||
|
|
||||||
tjfi.runTest(context.Background(), t, &Config{Inputs: inputs})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunEventPullRequest(t *testing.T) {
|
func TestRunEventPullRequest(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
@@ -637,29 +534,3 @@ func TestRunEventPullRequest(t *testing.T) {
|
|||||||
|
|
||||||
tjfi.runTest(context.Background(), t, &Config{EventPath: filepath.Join(workdir, workflowPath, "event.json")})
|
tjfi.runTest(context.Background(), t, &Config{EventPath: filepath.Join(workdir, workflowPath, "event.json")})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
requireDocker(t)
|
|
||||||
workflowPath := "matrix-with-user-inclusions"
|
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
|
||||||
workdir: workdir,
|
|
||||||
workflowPath: workflowPath,
|
|
||||||
eventName: "push",
|
|
||||||
errorMessage: "",
|
|
||||||
platforms: platforms,
|
|
||||||
}
|
|
||||||
|
|
||||||
matrix := map[string]map[string]bool{
|
|
||||||
"node": {
|
|
||||||
"8": true,
|
|
||||||
"8.x": true,
|
|
||||||
},
|
|
||||||
"os": {
|
|
||||||
"ubuntu-18.04": true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
tjfi.runTest(context.Background(), t, &Config{Matrix: matrix})
|
|
||||||
}
|
|
||||||
+71
-69
@@ -15,6 +15,7 @@ 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/internal/pkg/telemetry"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
@@ -58,53 +59,56 @@ func (s stepStage) String() string {
|
|||||||
func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error {
|
func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error {
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx)
|
err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
setter(ctx, map[string]string{"name": k}, v)
|
setter(ctx, map[string]string{"name": k}, v)
|
||||||
}
|
}
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
|
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) (err error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
stepModel := step.getStepModel()
|
||||||
|
|
||||||
ifExpression := step.getIfExpression(ctx, stage)
|
|
||||||
rc.CurrentStep = stepModel.ID
|
|
||||||
|
|
||||||
stepResult := &model.StepResult{
|
stepResult := &model.StepResult{
|
||||||
Outcome: model.StepStatusSuccess,
|
Outcome: model.StepStatusSuccess,
|
||||||
Conclusion: model.StepStatusSuccess,
|
Conclusion: model.StepStatusSuccess,
|
||||||
Outputs: make(map[string]string),
|
Outputs: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
ctx, endStep := telemetry.StartStep(ctx, stepModel, stage.String())
|
||||||
|
defer func() { endStep(err, stepResult) }()
|
||||||
|
|
||||||
|
ifExpression := step.getIfExpression(ctx, stage)
|
||||||
|
rc.CurrentStep = stepModel.ID
|
||||||
|
|
||||||
if stage == stepStageMain {
|
if stage == stepStageMain {
|
||||||
rc.StepResults[rc.CurrentStep] = stepResult
|
rc.StepResults[rc.CurrentStep] = stepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
err := setupEnv(ctx, step)
|
err = setupEnv(ctx, step)
|
||||||
if err != nil {
|
var runStep bool
|
||||||
return err
|
if err == nil {
|
||||||
|
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
|
||||||
|
}
|
||||||
|
if err == nil && runStep {
|
||||||
|
err = setupInputs(ctx, step)
|
||||||
}
|
}
|
||||||
|
|
||||||
runStep, err := isStepEnabled(ctx, ifExpression, step, stage)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stepResult.Conclusion = model.StepStatusFailure
|
stepResult.Conclusion = model.StepStatusFailure
|
||||||
stepResult.Outcome = model.StepStatusFailure
|
stepResult.Outcome = model.StepStatusFailure
|
||||||
|
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepModel)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !runStep {
|
if !runStep {
|
||||||
stepResult.Conclusion = model.StepStatusSkipped
|
stepResult.Conclusion = model.StepStatusSkipped
|
||||||
stepResult.Outcome = model.StepStatusSkipped
|
stepResult.Outcome = model.StepStatusSkipped
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
|
logger.WithField("stepResult", stepResult.Conclusion).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
stepString := rc.ExprEval.Interpolate(ctx, stepModel.String())
|
stepString := rc.ExprEval.InterpolateName(ctx, stepModel.String())
|
||||||
if strings.Contains(stepString, "::add-mask::") {
|
if strings.Contains(stepString, "::add-mask::") {
|
||||||
stepString = "add-mask command"
|
stepString = "add-mask command"
|
||||||
}
|
}
|
||||||
@@ -160,11 +164,11 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if topRC.summaryFileInitialized == nil {
|
if topRC.summaryFileInitialized == nil {
|
||||||
topRC.summaryFileInitialized = map[int]bool{}
|
topRC.summaryFileInitialized = map[int]bool{}
|
||||||
}
|
}
|
||||||
|
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||||
if !topRC.summaryFileInitialized[stepSummaryIndex] {
|
if !topRC.summaryFileInitialized[stepSummaryIndex] {
|
||||||
files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})
|
_ = rc.JobContainer.Copy(actPath, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})(ctx)
|
||||||
topRC.summaryFileInitialized[stepSummaryIndex] = true
|
topRC.summaryFileInitialized[stepSummaryIndex] = true
|
||||||
}
|
}
|
||||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
|
||||||
|
|
||||||
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
|
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
|
||||||
// Cloned: the step executor keeps writing to its own env map after this point, on a
|
// Cloned: the step executor keeps writing to its own env map after this point, on a
|
||||||
@@ -182,15 +186,29 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
err = insecureErr
|
err = insecureErr
|
||||||
}
|
}
|
||||||
|
if fileErr := processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
|
||||||
|
err = fileErr
|
||||||
|
}
|
||||||
|
if fileErr := processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
|
||||||
|
err = fileErr
|
||||||
|
}
|
||||||
|
if fileErr := processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
|
||||||
|
err = fileErr
|
||||||
|
}
|
||||||
|
if fileErr := rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
|
||||||
|
err = fileErr
|
||||||
|
}
|
||||||
|
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
|
logger.WithField("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString)
|
||||||
} else {
|
} else {
|
||||||
stepResult.Outcome = model.StepStatusFailure
|
stepResult.Outcome = model.StepStatusFailure
|
||||||
|
|
||||||
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
|
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
stepResult.Conclusion = model.StepStatusFailure
|
stepResult.Conclusion = model.StepStatusFailure
|
||||||
|
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
||||||
return parseErr
|
return parseErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,82 +223,61 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
|
|
||||||
// Infof: Errorf entries are promoted to the user log by the reporter,
|
// Infof: Errorf entries are promoted to the user log by the reporter,
|
||||||
// which would duplicate the ##[error] annotation emitted elsewhere.
|
// which would duplicate the ##[error] annotation emitted elsewhere.
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Failure - %s %s", stage, stepString)
|
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
||||||
}
|
|
||||||
// Process Runner File Commands
|
|
||||||
orgerr := err
|
|
||||||
err = processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnv)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if orgerr != nil {
|
|
||||||
return orgerr
|
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluateStepTimeout(ctx context.Context, exprEval ExpressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) {
|
func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) {
|
||||||
timeout := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
|
timeout, err := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
|
||||||
if timeout != "" {
|
if err != nil {
|
||||||
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
common.Logger(ctx).Errorf("An error occurred when attempting to determine the step timeout: %s", err)
|
||||||
|
} else if timeout != "" {
|
||||||
|
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeOutMinutes > 0 {
|
||||||
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
|
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ctx, func() {}
|
return ctx, func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupEnv(ctx context.Context, step step) error { //nolint:unparam // pre-existing issue from nektos/act
|
func setupEnv(ctx context.Context, step step) error {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
|
|
||||||
mergeEnv(ctx, step)
|
mergeEnv(ctx, step)
|
||||||
// merge step env last, since it should not be overwritten
|
|
||||||
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
|
|
||||||
|
|
||||||
|
var err error
|
||||||
exprEval := rc.NewExpressionEvaluator(ctx)
|
exprEval := rc.NewExpressionEvaluator(ctx)
|
||||||
for k, v := range *step.getEnv() {
|
stepEnv := step.getStepModel().Environment()
|
||||||
if !strings.HasPrefix(k, "INPUT_") {
|
for k, v := range stepEnv {
|
||||||
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v)
|
if stepEnv[k], err = exprEval.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// after we have an evaluated step context, update the expressions evaluator with a new env context
|
// merge step env last, since it should not be overwritten
|
||||||
// you can use step level env in the with property of a uses construct
|
mergeIntoMap(step, step.getEnv(), stepEnv)
|
||||||
exprEval = rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv())
|
return nil
|
||||||
for k, v := range *step.getEnv() {
|
}
|
||||||
if strings.HasPrefix(k, "INPUT_") {
|
|
||||||
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v)
|
// setupInputs evaluates `with` once the condition passed, as GitHub does, so every consumer reads the evaluated With.
|
||||||
}
|
func setupInputs(ctx context.Context, step step) error {
|
||||||
|
stepModel := step.getStepModel()
|
||||||
|
if stepModel.RawWith.Kind == 0 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := model.DecodeEvaluated("with", stepModel.RawWith, step.getRunContext().NewStepExpressionEvaluator(ctx, step).shared(ctx).EvaluateYamlNode, &stepModel.With); err != nil {
|
||||||
// For Gitea, reduce log noise
|
return err
|
||||||
// common.Logger(ctx).Debugf("setupEnv => %v", *step.getEnv())
|
}
|
||||||
|
mergeIntoMap(step, step.getEnv(), (&model.Step{With: stepModel.With}).GetEnv())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeEnv(ctx context.Context, step step) {
|
func mergeEnv(ctx context.Context, step step) {
|
||||||
env := step.getEnv()
|
env := step.getEnv()
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
job := rc.Run.Job()
|
|
||||||
|
|
||||||
c := job.Container()
|
// container env is the image's baseline, which job env and $GITHUB_ENV override
|
||||||
if c != nil {
|
mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
|
||||||
mergeIntoMap(step, env, rc.GetEnv(), c.Env)
|
|
||||||
} else {
|
|
||||||
mergeIntoMap(step, env, rc.GetEnv())
|
|
||||||
}
|
|
||||||
|
|
||||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
|
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
|
||||||
}
|
}
|
||||||
@@ -295,7 +292,12 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage)
|
|||||||
defaultStatusCheck = exprparser.DefaultStatusCheckSuccess
|
defaultStatusCheck = exprparser.DefaultStatusCheckSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
runStep, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, defaultStatusCheck)
|
// success() and failure() in a composite's own main steps read the composite's result, other stages the job's
|
||||||
|
jobContext := rc.getJobContext()
|
||||||
|
if rc.Parent != nil && stage == stepStageMain && jobContext.Status != "cancelled" {
|
||||||
|
jobContext.Status = rc.ownStatus()
|
||||||
|
}
|
||||||
|
runStep, err := EvalBool(ctx, rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, jobContext), expr, defaultStatusCheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
|
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/action"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/action/checkout"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var builtinActions = map[string]func(context.Context, *action.Context) error{
|
||||||
|
"checkout": checkout.Main,
|
||||||
|
}
|
||||||
|
|
||||||
|
type stepActionBuiltin struct {
|
||||||
|
Step *model.Step
|
||||||
|
RunContext *RunContext
|
||||||
|
run func(context.Context, *action.Context) error
|
||||||
|
env map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) pre() common.Executor { return common.NewPipelineExecutor() }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) main() common.Executor {
|
||||||
|
sab.env = map[string]string{}
|
||||||
|
return runStepExecutor(sab, stepStageMain, func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
printRunActionHeader(ctx, sab.Step, sab.env, sab.RunContext)
|
||||||
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
|
defer rawLogger.Infof("::endgroup::")
|
||||||
|
|
||||||
|
rc := sab.RunContext
|
||||||
|
return sab.run(ctx, &action.Context{
|
||||||
|
Container: rc.JobContainer,
|
||||||
|
Github: rc.getGithubContext(ctx),
|
||||||
|
Inputs: sab.Step.With,
|
||||||
|
HostWorkdir: rc.Config.Workdir,
|
||||||
|
Workspace: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||||
|
BindWorkdir: rc.Config.BindWorkdir,
|
||||||
|
NoSkipCheckout: rc.Config.NoSkipCheckout,
|
||||||
|
UseGitIgnore: rc.Config.UseGitIgnore,
|
||||||
|
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) post() common.Executor { return common.NewPipelineExecutor() }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getRunContext() *RunContext { return sab.RunContext }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||||
|
return sab.RunContext.getGithubContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getStepModel() *model.Step { return sab.Step }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getEnv() *map[string]string { return &sab.env }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getIfExpression(_ context.Context, _ stepStage) string {
|
||||||
|
return sab.Step.If.Value
|
||||||
|
}
|
||||||
@@ -33,10 +33,7 @@ type stepActionLocal struct {
|
|||||||
|
|
||||||
func (sal *stepActionLocal) pre() common.Executor {
|
func (sal *stepActionLocal) pre() common.Executor {
|
||||||
sal.env = map[string]string{}
|
sal.env = map[string]string{}
|
||||||
|
return common.NewPipelineExecutor()
|
||||||
return func(ctx context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sal *stepActionLocal) main() common.Executor {
|
func (sal *stepActionLocal) main() common.Executor {
|
||||||
@@ -50,39 +47,37 @@ func (sal *stepActionLocal) main() common.Executor {
|
|||||||
defer rawLogger.Infof("::endgroup::")
|
defer rawLogger.Infof("::endgroup::")
|
||||||
|
|
||||||
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
|
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
|
||||||
|
_, containerActionPath := getContainerActionPaths(sal.Step, path.Join(actionDir, ""), sal.RunContext)
|
||||||
|
|
||||||
localReader := func(ctx context.Context) actionYamlReader {
|
localReader := func(filename string) (io.Reader, io.Closer, error) {
|
||||||
_, cpath := getContainerActionPaths(sal.Step, path.Join(actionDir, ""), sal.RunContext)
|
spath := path.Join(containerActionPath, filename)
|
||||||
return func(filename string) (io.Reader, io.Closer, error) {
|
for range maxSymlinkDepth {
|
||||||
spath := path.Join(cpath, filename)
|
tars, err := sal.RunContext.JobContainer.GetContainerArchive(ctx, spath)
|
||||||
for range maxSymlinkDepth {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
tars, err := sal.RunContext.JobContainer.GetContainerArchive(ctx, spath)
|
return nil, nil, err
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
} else if err != nil {
|
||||||
|
return nil, nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
treader := tar.NewReader(tars)
|
||||||
|
header, err := treader.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil, nil, os.ErrNotExist
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||||
|
spath, err = symlinkJoin(spath, header.Linkname, containerActionPath)
|
||||||
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
} else if err != nil {
|
|
||||||
return nil, nil, fs.ErrNotExist
|
|
||||||
}
|
}
|
||||||
treader := tar.NewReader(tars)
|
} else {
|
||||||
header, err := treader.Next()
|
return treader, tars, nil
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
return nil, nil, os.ErrNotExist
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
|
|
||||||
spath, err = symlinkJoin(spath, header.Linkname, cpath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return treader, tars, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil, nil, fmt.Errorf("max depth %d of symlinks exceeded while reading %s", maxSymlinkDepth, spath)
|
|
||||||
}
|
}
|
||||||
|
return nil, nil, fmt.Errorf("max depth %d of symlinks exceeded while reading %s", maxSymlinkDepth, spath)
|
||||||
}
|
}
|
||||||
|
|
||||||
actionModel, err := sal.readAction(ctx, sal.Step, actionDir, "", localReader(ctx), os.WriteFile)
|
actionModel, err := sal.readAction(ctx, sal.Step, actionDir, "", localReader, os.WriteFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -127,15 +122,19 @@ func (sal *stepActionLocal) getActionModel() *model.Action {
|
|||||||
return sal.action
|
return sal.action
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) *RunContext {
|
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
||||||
if sal.compositeRunContext == nil {
|
if sal.compositeRunContext == nil {
|
||||||
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
|
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
|
||||||
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
|
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
|
||||||
|
|
||||||
sal.compositeRunContext = newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
||||||
sal.compositeSteps = sal.compositeRunContext.compositeExecutor(sal.action)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sal.compositeRunContext = compositeRunContext
|
||||||
|
sal.compositeSteps = compositeRunContext.compositeExecutor(sal.action)
|
||||||
}
|
}
|
||||||
return sal.compositeRunContext
|
return sal.compositeRunContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
|
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
|
||||||
|
|||||||
@@ -74,27 +74,17 @@ func TestStepActionLocalTest(t *testing.T) {
|
|||||||
salm.On("readAction", sal.Step, filepath.Clean("/tmp/path/to/action"), "", mock.Anything, mock.Anything).
|
salm.On("readAction", sal.Step, filepath.Clean("/tmp/path/to/action"), "", mock.Anything, mock.Anything).
|
||||||
Return(&model.Action{}, nil)
|
Return(&model.Action{}, nil)
|
||||||
|
|
||||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(func(ctx context.Context) error {
|
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||||
|
|
||||||
salm.On("runAction", sal, filepath.Clean("/tmp/path/to/action"), (*remoteAction)(nil)).Return(func(ctx context.Context) error {
|
salm.On("runAction", sal, filepath.Clean("/tmp/path/to/action"), (*remoteAction)(nil)).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
err := sal.pre()(ctx)
|
err := sal.pre()(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
@@ -264,26 +254,19 @@ func TestStepActionLocalPost(t *testing.T) {
|
|||||||
if tt.mocks.exec {
|
if tt.mocks.exec {
|
||||||
suffixMatcher := func(suffix string) any {
|
suffixMatcher := func(suffix string) any {
|
||||||
return mock.MatchedBy(func(array []string) bool {
|
return mock.MatchedBy(func(array []string) bool {
|
||||||
return strings.HasSuffix(array[1], suffix)
|
return len(array) == 3 && array[0] == "node" && array[1] == "--preserve-symlinks-main" &&
|
||||||
|
strings.HasSuffix(array[2], suffix)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err })
|
cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err })
|
||||||
|
|
||||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(func(ctx context.Context) error {
|
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -13,7 +12,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -33,7 +31,6 @@ type stepActionRemote struct {
|
|||||||
action *model.Action
|
action *model.Action
|
||||||
env map[string]string
|
env map[string]string
|
||||||
remoteAction *remoteAction
|
remoteAction *remoteAction
|
||||||
cacheDir string
|
|
||||||
resolvedSha string
|
resolvedSha string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,74 +50,21 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
// Since actions can specify the download source via a url prefix.
|
// Since actions can specify the download source via a url prefix.
|
||||||
// The prefix may contain some sensitive information that needs to be stored in secrets,
|
// The prefix may contain some sensitive information that needs to be stored in secrets,
|
||||||
// so we need to interpolate the expression value for uses first.
|
// so we need to interpolate the expression value for uses first.
|
||||||
sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
|
uses, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate uses: %w", err)
|
||||||
|
}
|
||||||
|
sar.Step.Uses = uses
|
||||||
|
|
||||||
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
||||||
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) {
|
if sar.remoteAction, err = newRemoteAction(sar.Step.Uses, github, sar.RunContext.Config.GitHubInstance); err != nil {
|
||||||
sar.remoteAction = newSelfRepoAction(sar.Step.Uses, github)
|
return err
|
||||||
} else {
|
|
||||||
sar.remoteAction = newRemoteAction(sar.Step.Uses)
|
|
||||||
}
|
|
||||||
if sar.remoteAction == nil {
|
|
||||||
return fmt.Errorf("Expected format {org}/{repo}[/path]@ref or %s{path}. Actual '%s' Input string was not in a correct format", selfRepoPrefix, sar.Step.Uses)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
||||||
common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
|
common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, action := range sar.RunContext.Config.ReplaceGheActionWithGithubCom {
|
|
||||||
if strings.EqualFold(fmt.Sprintf("%s/%s", sar.remoteAction.Org, sar.remoteAction.Repo), action) {
|
|
||||||
sar.remoteAction.URL = "https://github.com"
|
|
||||||
github.Token = sar.RunContext.Config.ReplaceGheActionTokenWithGithubCom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Actions served from the action cache are read out of a git object store rather than a
|
|
||||||
// directory, so they never reach the bundle patch below and keep to the v1 cache API.
|
|
||||||
if sar.RunContext.Config.ActionCache != nil {
|
|
||||||
cache := sar.RunContext.Config.ActionCache
|
|
||||||
|
|
||||||
var err error
|
|
||||||
sar.cacheDir = fmt.Sprintf("%s/%s", sar.remoteAction.Org, sar.remoteAction.Repo)
|
|
||||||
repoURL := sar.remoteAction.URL + "/" + sar.cacheDir
|
|
||||||
repoRef := sar.remoteAction.Ref
|
|
||||||
sar.resolvedSha, err = cache.Fetch(ctx, sar.cacheDir, repoURL, repoRef, github.Token)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to fetch \"%s\" version \"%s\": %w", repoURL, repoRef, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteReader := func(ctx context.Context) actionYamlReader {
|
|
||||||
return func(filename string) (io.Reader, io.Closer, error) {
|
|
||||||
spath := path.Join(sar.remoteAction.Path, filename)
|
|
||||||
for range maxSymlinkDepth {
|
|
||||||
tars, err := cache.GetTarArchive(ctx, sar.cacheDir, sar.resolvedSha, spath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, os.ErrNotExist
|
|
||||||
}
|
|
||||||
treader := tar.NewReader(tars)
|
|
||||||
header, err := treader.Next()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, os.ErrNotExist
|
|
||||||
}
|
|
||||||
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
|
|
||||||
spath, err = symlinkJoin(spath, header.Linkname, ".")
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return treader, tars, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil, fmt.Errorf("max depth %d of symlinks exceeded while reading %s", maxSymlinkDepth, spath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actionModel, err := sar.readAction(ctx, sar.Step, sar.resolvedSha, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile)
|
|
||||||
sar.action = actionModel
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
actionDir := sar.actionDir()
|
actionDir := sar.actionDir()
|
||||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||||
// For Gitea
|
// For Gitea
|
||||||
@@ -148,12 +92,13 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
var ntErr common.Executor
|
var ntErr common.Executor
|
||||||
if err := gitClone(ctx); err != nil {
|
if err := gitClone(ctx); err != nil {
|
||||||
var refErr *git.Error
|
var refErr *git.Error
|
||||||
if errors.As(err, &refErr) && errors.Is(err, git.ErrShortRef) {
|
switch {
|
||||||
return fmt.Errorf("Unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
case errors.As(err, &refErr) && errors.Is(err, git.ErrShortRef):
|
||||||
|
return fmt.Errorf("unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
||||||
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
||||||
} else if errors.Is(err, gogit.ErrForceNeeded) { // TODO: figure out if it will be easy to shadow/alias go-git err's
|
case errors.Is(err, gogit.ErrForceNeeded): // TODO: figure out if it will be easy to shadow/alias go-git err's
|
||||||
ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err)
|
ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err)
|
||||||
} else {
|
default:
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,24 +110,19 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
sar.resolvedSha = sha
|
sar.resolvedSha = sha
|
||||||
}
|
}
|
||||||
|
|
||||||
remoteReader := func(ctx context.Context) actionYamlReader { //nolint:unparam // pre-existing issue from nektos/act
|
remoteReader := func(filename string) (io.Reader, io.Closer, error) {
|
||||||
return func(filename string) (io.Reader, io.Closer, error) {
|
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
|
||||||
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
|
return f, f, err
|
||||||
return f, f, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
ntErr,
|
ntErr,
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
defer git.AcquireCloneLock(actionDir)()
|
defer git.AcquireCloneLock(actionDir)()
|
||||||
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile)
|
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile)
|
||||||
sar.action = actionModel
|
sar.action = actionModel
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
// A stage of its own: it takes the same clone lock, and it has to land before
|
|
||||||
// runAction copies the action into the job container.
|
|
||||||
sar.patchActionToolkit,
|
|
||||||
)(ctx)
|
)(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,7 +141,7 @@ func (sar *stepActionRemote) pre() common.Executor {
|
|||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
sar.prepareActionExecutor(),
|
sar.prepareActionExecutor(),
|
||||||
runStepExecutor(sar, stepStagePre, sar.revertToolkitOnFailure(runPreStep(sar))).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
runStepExecutor(sar, stepStagePre, runPreStep(sar)).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) main() common.Executor {
|
func (sar *stepActionRemote) main() common.Executor {
|
||||||
@@ -218,52 +158,17 @@ func (sar *stepActionRemote) main() common.Executor {
|
|||||||
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
|
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
eval := sar.RunContext.NewExpressionEvaluator(ctx)
|
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), sar.Step.With["path"])
|
||||||
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), eval.Interpolate(ctx, sar.Step.With["path"]))
|
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
|
||||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actionDir := sar.actionDir()
|
return sar.runAction(sar, sar.actionDir(), sar.remoteAction)(ctx)
|
||||||
|
|
||||||
return sar.revertToolkitOnFailure(sar.runAction(sar, actionDir, sar.remoteAction))(ctx)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) post() common.Executor {
|
func (sar *stepActionRemote) post() common.Executor {
|
||||||
return runStepExecutor(sar, stepStagePost, sar.revertToolkitOnFailure(runPostStep(sar))).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
return runStepExecutor(sar, stepStagePost, runPostStep(sar)).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||||
}
|
|
||||||
|
|
||||||
// toolkitBundles is the action directory and the entrypoints the toolkit may live in.
|
|
||||||
func (sar *stepActionRemote) toolkitBundles() (string, []string) {
|
|
||||||
if sar.remoteAction == nil {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
dir := sar.actionDir()
|
|
||||||
return dir, actionScriptPaths(filepath.Join(dir, sar.remoteAction.Path), sar.action)
|
|
||||||
}
|
|
||||||
|
|
||||||
// patchActionToolkit edits the bundled toolkit so it works against Gitea: the artifact actions
|
|
||||||
// stop refusing, and the cache client keeps to the cache server whichever API version it picks.
|
|
||||||
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
|
||||||
if sar.RunContext.Config.PatchToolkit {
|
|
||||||
dir, scripts := sar.toolkitBundles()
|
|
||||||
patchToolkit(ctx, dir, scripts)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// revertToolkitOnFailure restores the untouched bundles when the action fails, so a later job
|
|
||||||
// runs it as shipped rather than repeating a failure the patch may have caused.
|
|
||||||
func (sar *stepActionRemote) revertToolkitOnFailure(exec common.Executor) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
err := exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
dir, scripts := sar.toolkitBundles()
|
|
||||||
revertToolkit(ctx, dir, scripts)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) actionDir() string {
|
func (sar *stepActionRemote) actionDir() string {
|
||||||
@@ -321,14 +226,18 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
|
|||||||
return sar.action
|
return sar.action
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext {
|
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
||||||
if sar.compositeRunContext == nil {
|
if sar.compositeRunContext == nil {
|
||||||
actionDir := sar.actionDir()
|
actionDir := sar.actionDir()
|
||||||
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
||||||
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
||||||
|
|
||||||
sar.compositeRunContext = newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
|
compositeRunContext, err := newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
|
||||||
sar.compositeSteps = sar.compositeRunContext.compositeExecutor(sar.action)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sar.compositeRunContext = compositeRunContext
|
||||||
|
sar.compositeSteps = compositeRunContext.compositeExecutor(sar.action)
|
||||||
} else {
|
} else {
|
||||||
// Re-evaluate environment here. For remote actions the environment
|
// Re-evaluate environment here. For remote actions the environment
|
||||||
// need to be re-created for every stage (pre, main, post) as there
|
// need to be re-created for every stage (pre, main, post) as there
|
||||||
@@ -336,11 +245,14 @@ func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunCon
|
|||||||
// stages are executed. (e.g. the output of another action is the
|
// stages are executed. (e.g. the output of another action is the
|
||||||
// input for this action during the main stage, but the env
|
// input for this action during the main stage, but the env
|
||||||
// was already created during the pre stage)
|
// was already created during the pre stage)
|
||||||
env := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar)
|
env, err := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar)
|
||||||
sar.compositeRunContext.Env = env
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sar.compositeRunContext.setCompositeActionEnv(env)
|
||||||
sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath
|
sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath
|
||||||
}
|
}
|
||||||
return sar.compositeRunContext
|
return sar.compositeRunContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
||||||
@@ -402,61 +314,34 @@ func (ra *remoteAction) IsCheckout() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSelfRepoAction resolves `$/{path}` against the enclosing composite action, falling back to the workflow's own repo and commit.
|
// newRemoteAction resolves `self:` on instanceURL and `$/` in the enclosing composite action, else the workflow's repo and commit.
|
||||||
func newSelfRepoAction(action string, github *model.GithubContext) *remoteAction {
|
func newRemoteAction(action string, github *model.GithubContext, instanceURL string) (*remoteAction, error) {
|
||||||
subPath := strings.TrimLeft(strings.TrimPrefix(action, selfRepoPrefix), "/")
|
uses, err := model.ParseActionUses(action)
|
||||||
if subPath == "" || strings.Contains(subPath, "@") || path.Clean("/"+subPath) != "/"+subPath { // rooted, so a leading ".." is rejected too
|
if err != nil {
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
repo, ref := github.ActionRepository, github.ActionRef
|
ra := &remoteAction{URL: uses.URL, Org: uses.Owner, Repo: uses.Repo, Path: uses.Path, Ref: uses.Ref}
|
||||||
if repo == "" || ref == "" {
|
switch uses.Kind {
|
||||||
repo, ref = github.Repository, github.Sha
|
case model.ActionUsesInstance:
|
||||||
}
|
if instanceURL == "" {
|
||||||
org, name, _ := strings.Cut(repo, "/")
|
return nil, fmt.Errorf("unable to resolve %q without a Gitea instance", action)
|
||||||
if org == "" || name == "" || ref == "" {
|
}
|
||||||
return nil
|
ra.URL = instanceURL
|
||||||
}
|
if !strings.Contains(instanceURL, "://") {
|
||||||
return &remoteAction{URL: github.ServerURL, Org: org, Repo: name, Path: subPath, Ref: ref}
|
ra.URL = "https://" + instanceURL
|
||||||
}
|
}
|
||||||
|
case model.ActionUsesSelfRepo:
|
||||||
func newRemoteAction(action string) *remoteAction {
|
repo, ref := github.ActionRepository, github.ActionRef
|
||||||
// support http(s)://host/owner/repo@v3
|
if repo == "" || ref == "" {
|
||||||
for _, schema := range []string{"https://", "http://", "ssh://"} {
|
repo, ref = github.Repository, github.Sha
|
||||||
if after, ok := strings.CutPrefix(action, schema); ok {
|
}
|
||||||
splits := strings.SplitN(after, "/", 2)
|
ra.Org, ra.Repo, _ = strings.Cut(repo, "/")
|
||||||
if len(splits) != 2 {
|
ra.URL, ra.Ref = github.ServerURL, ref
|
||||||
return nil
|
if ra.Org == "" || ra.Repo == "" || ra.Ref == "" {
|
||||||
}
|
return nil, fmt.Errorf("unable to resolve %q without a repository and commit", action)
|
||||||
ret := parseAction(splits[1])
|
|
||||||
if ret == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ret.URL = schema + splits[0]
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ra, nil
|
||||||
return parseAction(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAction(action string) *remoteAction {
|
|
||||||
// GitHub's document[^] describes:
|
|
||||||
// > We strongly recommend that you include the version of
|
|
||||||
// > the action you are using by specifying a Git ref, SHA, or Docker tag number.
|
|
||||||
// Actually, the workflow stops if there is the uses directive that hasn't @ref.
|
|
||||||
// [^]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
|
|
||||||
r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`)
|
|
||||||
matches := r.FindStringSubmatch(action)
|
|
||||||
if len(matches) < 7 || matches[6] == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &remoteAction{
|
|
||||||
Org: matches[1],
|
|
||||||
Repo: matches[2],
|
|
||||||
Path: matches[4],
|
|
||||||
Ref: matches[6],
|
|
||||||
URL: "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeFilename(s string) string {
|
func safeFilename(s string) string {
|
||||||
|
|||||||
@@ -31,6 +31,52 @@ type stepActionRemoteMocks struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func actionDirSuffix(suffix string) any {
|
||||||
|
return mock.MatchedBy(func(actionDir string) bool { return strings.HasSuffix(actionDir, suffix) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCloneExecutor(t *testing.T, executor func(git.NewGitCloneExecutorInput) common.Executor) {
|
||||||
|
original := stepActionRemoteNewCloneExecutor
|
||||||
|
stepActionRemoteNewCloneExecutor = executor
|
||||||
|
t.Cleanup(func() { stepActionRemoteNewCloneExecutor = original })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortSHAActionRejected(t *testing.T) {
|
||||||
|
actionRoot := t.TempDir()
|
||||||
|
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
|
||||||
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
||||||
|
gitMust(t, "", "init", "--initial-branch=main", repo)
|
||||||
|
gitMust(t, repo, "config", "user.email", "test@test")
|
||||||
|
gitMust(t, repo, "config", "user.name", "test")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
|
||||||
|
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
||||||
|
gitMust(t, repo, "add", ".")
|
||||||
|
gitMust(t, repo, "commit", "-m", "initial")
|
||||||
|
output, err := exec.Command("git", "-C", repo, "rev-parse", "--short=7", "HEAD").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
workflowDir := t.TempDir()
|
||||||
|
workflow := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", strings.TrimSpace(string(output)))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "push.yml"), []byte(workflow), 0o644))
|
||||||
|
|
||||||
|
runner, err := New(&Config{
|
||||||
|
Workdir: workflowDir,
|
||||||
|
EventName: "push",
|
||||||
|
GitHubInstance: "github.com",
|
||||||
|
DefaultActionInstance: actionRoot,
|
||||||
|
ContainerMaxLifetime: time.Hour,
|
||||||
|
PlatformPicker: func([]string) string { return baseImage },
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
planner, err := model.NewWorkflowPlanner(workflowDir, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
plan, err := planner.PlanEvent("push")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = runner.NewPlanExecutor(plan)(common.WithDryrun(t.Context(), true))
|
||||||
|
require.ErrorContains(t, err, "shortened version of a commit SHA")
|
||||||
|
}
|
||||||
|
|
||||||
func (sarm *stepActionRemoteMocks) readAction(_ context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) {
|
func (sarm *stepActionRemoteMocks) readAction(_ context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) {
|
||||||
args := sarm.Called(step, actionDir, actionPath, readFile, writeFile)
|
args := sarm.Called(step, actionDir, actionPath, readFile, writeFile)
|
||||||
return args.Get(0).(*model.Action), args.Error(1)
|
return args.Get(0).(*model.Action), args.Error(1)
|
||||||
@@ -136,16 +182,12 @@ func TestStepActionRemote(t *testing.T) {
|
|||||||
|
|
||||||
clonedAction := false
|
clonedAction := false
|
||||||
|
|
||||||
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
|
setCloneExecutor(t, func(input git.NewGitCloneExecutorInput) common.Executor {
|
||||||
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
clonedAction = true
|
clonedAction = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
defer (func() {
|
|
||||||
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
|
|
||||||
})()
|
|
||||||
|
|
||||||
sar := &stepActionRemote{
|
sar := &stepActionRemote{
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
@@ -170,33 +212,19 @@ func TestStepActionRemote(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
suffixMatcher := func(suffix string) any {
|
|
||||||
return mock.MatchedBy(func(actionDir string) bool {
|
|
||||||
return strings.HasSuffix(actionDir, suffix)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.mocks.read {
|
if tt.mocks.read {
|
||||||
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||||
}
|
}
|
||||||
if tt.mocks.run {
|
if tt.mocks.run {
|
||||||
sarm.On("runAction", sar, suffixMatcher(sar.Step.UsesHash()), newRemoteAction(sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError })
|
sarm.On("runAction", sar, actionDirSuffix(sar.Step.UsesHash()), mustNewRemoteAction(t, sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError })
|
||||||
|
|
||||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(func(ctx context.Context) error {
|
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||||
}
|
}
|
||||||
@@ -214,279 +242,61 @@ func TestStepActionRemote(t *testing.T) {
|
|||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepActionRemotePre(t *testing.T) {
|
t.Run("refreshes composite inputs without leaking environment", func(t *testing.T) {
|
||||||
table := []struct {
|
step := &stepActionRemote{
|
||||||
name string
|
Step: &model.Step{Uses: "org/composite@v1", With: map[string]string{"SHARED": "first"}},
|
||||||
stepModel *model.Step
|
RunContext: &RunContext{Config: &Config{ActionCacheDir: t.TempDir()}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, JobContainer: &jobContainerMock{}},
|
||||||
}{
|
action: &model.Action{Inputs: map[string]model.Input{"shared": {}}},
|
||||||
{
|
env: map[string]string{},
|
||||||
name: "run-pre",
|
remoteAction: mustNewRemoteAction(t, "org/composite@v1"),
|
||||||
stepModel: &model.Step{
|
|
||||||
Uses: "org/repo/path@ref",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
clonedAction := false
|
|
||||||
sarm := &stepActionRemoteMocks{}
|
|
||||||
|
|
||||||
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
|
|
||||||
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
clonedAction = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer (func() {
|
|
||||||
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
|
|
||||||
})()
|
|
||||||
|
|
||||||
sar := &stepActionRemote{
|
|
||||||
Step: tt.stepModel,
|
|
||||||
RunContext: &RunContext{
|
|
||||||
Config: &Config{
|
|
||||||
GitHubInstance: "https://github.com",
|
|
||||||
ActionCacheDir: "/tmp/test-cache",
|
|
||||||
},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "1",
|
|
||||||
Workflow: &model.Workflow{
|
|
||||||
Jobs: map[string]*model.Job{
|
|
||||||
"1": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
readAction: sarm.readAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
suffixMatcher := func(suffix string) any {
|
|
||||||
return mock.MatchedBy(func(actionDir string) bool {
|
|
||||||
return strings.HasSuffix(actionDir, suffix)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "path", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
|
||||||
|
|
||||||
err := sar.pre()(ctx)
|
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.True(t, clonedAction)
|
|
||||||
|
|
||||||
sarm.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepActionRemotePreThroughAction(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
name string
|
|
||||||
stepModel *model.Step
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "run-pre",
|
|
||||||
stepModel: &model.Step{
|
|
||||||
Uses: "org/repo/path@ref",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
clonedAction := false
|
|
||||||
sarm := &stepActionRemoteMocks{}
|
|
||||||
|
|
||||||
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
|
|
||||||
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
if input.URL == "https://github.com/org/repo" {
|
|
||||||
clonedAction = true
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer (func() {
|
|
||||||
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
|
|
||||||
})()
|
|
||||||
|
|
||||||
sar := &stepActionRemote{
|
|
||||||
Step: tt.stepModel,
|
|
||||||
RunContext: &RunContext{
|
|
||||||
Config: &Config{
|
|
||||||
GitHubInstance: "https://enterprise.github.com",
|
|
||||||
ReplaceGheActionWithGithubCom: []string{"org/repo"},
|
|
||||||
ActionCacheDir: "/tmp/test-cache",
|
|
||||||
},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "1",
|
|
||||||
Workflow: &model.Workflow{
|
|
||||||
Jobs: map[string]*model.Job{
|
|
||||||
"1": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
readAction: sarm.readAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
suffixMatcher := func(suffix string) any {
|
|
||||||
return mock.MatchedBy(func(actionDir string) bool {
|
|
||||||
return strings.HasSuffix(actionDir, suffix)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "path", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
|
||||||
|
|
||||||
err := sar.pre()(ctx)
|
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.True(t, clonedAction)
|
|
||||||
|
|
||||||
sarm.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepActionRemotePreThroughActionToken(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
name string
|
|
||||||
stepModel *model.Step
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "run-pre",
|
|
||||||
stepModel: &model.Step{
|
|
||||||
Uses: "org/repo/path@ref",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range table {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var actualURL string
|
|
||||||
var actualToken string
|
|
||||||
sarm := &stepActionRemoteMocks{}
|
|
||||||
|
|
||||||
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
|
|
||||||
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
actualURL = input.URL
|
|
||||||
actualToken = input.Token
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer (func() {
|
|
||||||
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Use unique cache directory to ensure action gets cloned, not served from cache
|
|
||||||
uniqueCacheDir := fmt.Sprintf("/tmp/test-cache-token-%d", time.Now().UnixNano())
|
|
||||||
|
|
||||||
sar := &stepActionRemote{
|
|
||||||
Step: tt.stepModel,
|
|
||||||
RunContext: &RunContext{
|
|
||||||
Config: &Config{
|
|
||||||
GitHubInstance: "https://enterprise.github.com",
|
|
||||||
ReplaceGheActionWithGithubCom: []string{"org/repo"},
|
|
||||||
ReplaceGheActionTokenWithGithubCom: "PRIVATE_ACTIONS_TOKEN_ON_GITHUB",
|
|
||||||
ActionCacheDir: uniqueCacheDir,
|
|
||||||
Token: "PRIVATE_ACTIONS_TOKEN_ON_GITHUB",
|
|
||||||
},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "1",
|
|
||||||
Workflow: &model.Workflow{
|
|
||||||
Jobs: map[string]*model.Job{
|
|
||||||
"1": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
readAction: sarm.readAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
suffixMatcher := func(suffix string) any {
|
|
||||||
return mock.MatchedBy(func(actionDir string) bool {
|
|
||||||
return strings.HasSuffix(actionDir, suffix)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "path", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
|
||||||
|
|
||||||
err := sar.pre()(ctx)
|
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
// Verify that the clone was called (URL should be redirected to github.com)
|
|
||||||
assert.True(t, actualURL != "", "Expected clone to be called") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, "https://github.com/org/repo", actualURL, "URL should be redirected to github.com")
|
|
||||||
// Note: Token might be empty because getGitCloneToken doesn't check ReplaceGheActionTokenWithGithubCom
|
|
||||||
// The important part is that the URL replacement works
|
|
||||||
if actualToken != "" {
|
|
||||||
assert.Equal(t, "PRIVATE_ACTIONS_TOKEN_ON_GITHUB", actualToken, "If token is set, it should be the replacement token")
|
|
||||||
}
|
|
||||||
|
|
||||||
sarm.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepActionRemoteUsesGitHubInstanceWhenDefaultActionInstanceEmpty(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var actualURL string
|
|
||||||
sarm := &stepActionRemoteMocks{}
|
|
||||||
|
|
||||||
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
|
|
||||||
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
actualURL = input.URL
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
|
|
||||||
}()
|
|
||||||
|
|
||||||
sar := &stepActionRemote{
|
for _, value := range []string{"first", "second"} {
|
||||||
Step: &model.Step{
|
step.env["INPUT_SHARED"] = value
|
||||||
Uses: "actions/setup-go@v4",
|
composite, err := step.getCompositeRunContext(t.Context())
|
||||||
},
|
require.NoError(t, err)
|
||||||
RunContext: &RunContext{
|
assert.Equal(t, map[string]any{"shared": value}, composite.actionInputs)
|
||||||
Config: &Config{
|
assert.NotContains(t, composite.Env, "INPUT_SHARED")
|
||||||
GitHubInstance: "gitea.example",
|
}
|
||||||
DefaultActionInstance: "",
|
})
|
||||||
ActionCacheDir: t.TempDir(),
|
}
|
||||||
},
|
|
||||||
Run: &model.Run{
|
func TestStepActionRemotePrepare(t *testing.T) {
|
||||||
JobID: "1",
|
for _, test := range []struct {
|
||||||
Workflow: &model.Workflow{
|
name, uses, instance, actionPath, wantURL string
|
||||||
Jobs: map[string]*model.Job{
|
}{
|
||||||
"1": {},
|
{name: "nested action", uses: "org/repo/path@ref", instance: "https://github.com", actionPath: "path", wantURL: "https://github.com/org/repo"},
|
||||||
},
|
{name: "instance fallback", uses: "actions/setup-go@v4", instance: "gitea.example", wantURL: "https://gitea.example/actions/setup-go"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var actualURL string
|
||||||
|
setCloneExecutor(t, func(input git.NewGitCloneExecutorInput) common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
actualURL = input.URL
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
actionMocks := &stepActionRemoteMocks{}
|
||||||
|
action := &stepActionRemote{
|
||||||
|
Step: &model.Step{Uses: test.uses},
|
||||||
|
RunContext: &RunContext{
|
||||||
|
Config: &Config{GitHubInstance: test.instance, ActionCacheDir: t.TempDir()},
|
||||||
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{
|
||||||
|
Jobs: map[string]*model.Job{"1": {}},
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
},
|
readAction: actionMocks.readAction,
|
||||||
},
|
}
|
||||||
readAction: sarm.readAction,
|
actionMocks.On("readAction", action.Step, actionDirSuffix(action.Step.UsesHash()), test.actionPath,
|
||||||
}
|
mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||||
|
|
||||||
suffixMatcher := func(suffix string) any {
|
require.NoError(t, action.prepareActionExecutor()(t.Context()))
|
||||||
return mock.MatchedBy(func(actionDir string) bool {
|
assert.Equal(t, test.wantURL, actualURL)
|
||||||
return strings.HasSuffix(actionDir, suffix)
|
actionMocks.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
|
||||||
|
|
||||||
require.NoError(t, sar.prepareActionExecutor()(ctx))
|
|
||||||
assert.Equal(t, "https://gitea.example/actions/setup-go", actualURL)
|
|
||||||
sarm.AssertExpectations(t)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStepActionRemotePost(t *testing.T) {
|
func TestStepActionRemotePost(t *testing.T) {
|
||||||
@@ -654,36 +464,28 @@ func TestStepActionRemotePost(t *testing.T) {
|
|||||||
Step: tt.stepModel,
|
Step: tt.stepModel,
|
||||||
action: tt.actionModel,
|
action: tt.actionModel,
|
||||||
// post only ever runs after prepareActionExecutor resolved the action
|
// post only ever runs after prepareActionExecutor resolved the action
|
||||||
remoteAction: newRemoteAction(tt.stepModel.Uses),
|
remoteAction: mustNewRemoteAction(t, tt.stepModel.Uses),
|
||||||
}
|
}
|
||||||
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
if tt.mocks.exec {
|
if tt.mocks.exec {
|
||||||
// Use mock.MatchedBy to match the exec command with hash-based path
|
// Use mock.MatchedBy to match the exec command with hash-based path
|
||||||
execMatcher := mock.MatchedBy(func(args []string) bool {
|
execMatcher := mock.MatchedBy(func(args []string) bool {
|
||||||
if len(args) != 2 {
|
if len(args) != 3 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return args[0] == "node" && strings.Contains(args[1], "post.js")
|
return args[0] == "node" && args[1] == "--preserve-symlinks-main" && strings.Contains(args[2], "post.js")
|
||||||
})
|
})
|
||||||
|
|
||||||
cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err })
|
cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err })
|
||||||
|
|
||||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(func(ctx context.Context) error {
|
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||||
}
|
}
|
||||||
@@ -809,10 +611,21 @@ func Test_newRemoteAction(t *testing.T) {
|
|||||||
action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host
|
action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host
|
||||||
want: nil,
|
want: nil,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
action: "self:owner/repo/sub@v1",
|
||||||
|
want: &remoteAction{
|
||||||
|
URL: "https://gitea.example.com",
|
||||||
|
Org: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Path: "sub",
|
||||||
|
Ref: "v1",
|
||||||
|
},
|
||||||
|
wantCloneURL: "https://gitea.example.com/owner/repo",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.action, func(t *testing.T) {
|
t.Run(tt.action, func(t *testing.T) {
|
||||||
got := newRemoteAction(tt.action)
|
got, _ := newRemoteAction(tt.action, nil, "gitea.example.com")
|
||||||
assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action)
|
assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action)
|
||||||
cloneURL := ""
|
cloneURL := ""
|
||||||
if got != nil {
|
if got != nil {
|
||||||
@@ -821,9 +634,18 @@ func Test_newRemoteAction(t *testing.T) {
|
|||||||
assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action)
|
assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
_, err := newRemoteAction("self:owner/repo@v1", nil, "")
|
||||||
|
require.ErrorContains(t, err, "without a Gitea instance")
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_newSelfRepoAction(t *testing.T) {
|
func mustNewRemoteAction(t *testing.T, uses string) *remoteAction {
|
||||||
|
t.Helper()
|
||||||
|
action, err := newRemoteAction(uses, nil, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_newRemoteActionSelfRepo(t *testing.T) {
|
||||||
workflow := &model.GithubContext{
|
workflow := &model.GithubContext{
|
||||||
ServerURL: "https://gitea.example.com",
|
ServerURL: "https://gitea.example.com",
|
||||||
Repository: "owner/workflow-repo",
|
Repository: "owner/workflow-repo",
|
||||||
@@ -872,7 +694,8 @@ func Test_newSelfRepoAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
assert.Equal(t, tt.want, newSelfRepoAction(tt.action, tt.github))
|
got, _ := newRemoteAction(tt.action, tt.github, "")
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -902,7 +725,7 @@ func Test_remoteActionReference(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.uses, func(t *testing.T) {
|
t.Run(tt.uses, func(t *testing.T) {
|
||||||
assert.Equal(t, tt.want, newRemoteAction(tt.uses).Reference())
|
assert.Equal(t, tt.want, mustNewRemoteAction(t, tt.uses).Reference())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -959,7 +782,7 @@ func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
|
|||||||
func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
||||||
t.Run("reports the action and its resolved commit", func(t *testing.T) {
|
t.Run("reports the action and its resolved commit", func(t *testing.T) {
|
||||||
sar := &stepActionRemote{
|
sar := &stepActionRemote{
|
||||||
remoteAction: newRemoteAction("actions/checkout@v7"),
|
remoteAction: mustNewRemoteAction(t, "actions/checkout@v7"),
|
||||||
action: &model.Action{},
|
action: &model.Action{},
|
||||||
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
||||||
}
|
}
|
||||||
@@ -973,7 +796,7 @@ func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
|
t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
|
||||||
// The local checkout of the workflow's own repository resolves no action.
|
// The local checkout of the workflow's own repository resolves no action.
|
||||||
sar := &stepActionRemote{remoteAction: newRemoteAction("actions/checkout@v7")}
|
sar := &stepActionRemote{remoteAction: mustNewRemoteAction(t, "actions/checkout@v7")}
|
||||||
|
|
||||||
_, _, ok := sar.actionDownloadInfo()
|
_, _, ok := sar.actionDownloadInfo()
|
||||||
|
|
||||||
@@ -1032,14 +855,10 @@ func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
var capturedToken string
|
var capturedToken string
|
||||||
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
|
setCloneExecutor(t, func(input git.NewGitCloneExecutorInput) common.Executor {
|
||||||
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
|
|
||||||
capturedToken = input.Token
|
capturedToken = input.Token
|
||||||
return func(ctx context.Context) error { return nil }
|
return func(ctx context.Context) error { return nil }
|
||||||
}
|
})
|
||||||
defer (func() {
|
|
||||||
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
|
|
||||||
})()
|
|
||||||
|
|
||||||
sarm := &stepActionRemoteMocks{}
|
sarm := &stepActionRemoteMocks{}
|
||||||
sar := &stepActionRemote{
|
sar := &stepActionRemote{
|
||||||
@@ -1066,12 +885,7 @@ func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
suffixMatcher := func(suffix string) any {
|
sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||||
return mock.MatchedBy(func(actionDir string) bool {
|
|
||||||
return strings.HasSuffix(actionDir, suffix)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
|
||||||
|
|
||||||
err := sar.prepareActionExecutor()(ctx)
|
err := sar.prepareActionExecutor()(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -22,11 +21,7 @@ type stepDocker struct {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sd *stepDocker) pre() common.Executor {
|
func (sd *stepDocker) pre() common.Executor { return common.NewPipelineExecutor() }
|
||||||
return func(ctx context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sd *stepDocker) main() common.Executor {
|
func (sd *stepDocker) main() common.Executor {
|
||||||
sd.env = map[string]string{}
|
sd.env = map[string]string{}
|
||||||
@@ -34,11 +29,7 @@ func (sd *stepDocker) main() common.Executor {
|
|||||||
return runStepExecutor(sd, stepStageMain, sd.runUsesContainer())
|
return runStepExecutor(sd, stepStageMain, sd.runUsesContainer())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sd *stepDocker) post() common.Executor {
|
func (sd *stepDocker) post() common.Executor { return common.NewPipelineExecutor() }
|
||||||
return func(ctx context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sd *stepDocker) getRunContext() *RunContext {
|
func (sd *stepDocker) getRunContext() *RunContext {
|
||||||
return sd.RunContext
|
return sd.RunContext
|
||||||
@@ -66,75 +57,25 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
|
|||||||
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
image := strings.TrimPrefix(step.Uses, "docker://")
|
image := strings.TrimPrefix(step.Uses, "docker://")
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
cmd, err := shellquote.Split(step.With["args"])
|
||||||
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.With["args"]))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var entrypoint []string
|
var entrypoint []string
|
||||||
if entry := eval.Interpolate(ctx, step.With["entrypoint"]); entry != "" {
|
if entry := step.With["entrypoint"]; entry != "" {
|
||||||
entrypoint = []string{entry}
|
entrypoint = []string{entry}
|
||||||
}
|
}
|
||||||
|
|
||||||
stepContainer := sd.newStepContainer(ctx, image, cmd, entrypoint)
|
stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
stepContainer.Pull(rc.Config.ForcePull),
|
stepContainer.Pull(rc.Config.ForcePull),
|
||||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
stepContainer.Remove(),
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ContainerNewContainer = container.NewContainer
|
var ContainerNewContainer = container.NewContainer
|
||||||
|
|
||||||
func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, entrypoint []string) container.Container {
|
|
||||||
rc := sd.RunContext
|
|
||||||
step := sd.Step
|
|
||||||
|
|
||||||
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)
|
|
||||||
for k, v := range sd.env {
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
|
||||||
}
|
|
||||||
|
|
||||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
|
||||||
if rc.IsHostEnv(ctx) {
|
|
||||||
networkMode = "default"
|
|
||||||
}
|
|
||||||
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
|
||||||
Cmd: cmd,
|
|
||||||
Entrypoint: entrypoint,
|
|
||||||
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
|
||||||
Image: image,
|
|
||||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
|
|
||||||
Env: envList,
|
|
||||||
Mounts: mounts,
|
|
||||||
NetworkMode: networkMode,
|
|
||||||
Binds: binds,
|
|
||||||
Stdout: logWriter,
|
|
||||||
Stderr: logWriter,
|
|
||||||
Privileged: rc.Config.Privileged,
|
|
||||||
UsernsMode: rc.Config.UsernsMode,
|
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
|
||||||
AutoRemove: rc.Config.AutoRemove,
|
|
||||||
ValidVolumes: rc.validVolumes(),
|
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
|
||||||
})
|
|
||||||
return stepContainer
|
|
||||||
}
|
|
||||||
@@ -30,9 +30,9 @@ func TestStepDockerMain(t *testing.T) {
|
|||||||
input = containerInput
|
input = containerInput
|
||||||
return cm
|
return cm
|
||||||
}
|
}
|
||||||
defer (func() {
|
defer func() {
|
||||||
ContainerNewContainer = origContainerNewContainer
|
ContainerNewContainer = origContainerNewContainer
|
||||||
})()
|
}()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -69,41 +69,23 @@ func TestStepDockerMain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
cm.On("Pull", false).Return(func(ctx context.Context) error {
|
cm.On("Pull", false).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("Remove").Return(func(ctx context.Context) error {
|
cm.On("Remove").Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("Create", []string(nil), []string(nil)).Return(func(ctx context.Context) error {
|
cm.On("Create", []string(nil), []string(nil)).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("Start", true).Return(func(ctx context.Context) error {
|
cm.On("Start", true).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("Close").Return(func(ctx context.Context) error {
|
cm.On("Close").Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(func(ctx context.Context) error {
|
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/statecmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(func(ctx context.Context) error {
|
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||||
|
|
||||||
@@ -115,47 +97,11 @@ func TestStepDockerMain(t *testing.T) {
|
|||||||
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit pull credentials for docker:// action containers.
|
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit pull credentials for docker:// action containers.
|
||||||
assert.Empty(t, input.Username)
|
assert.Empty(t, input.Username)
|
||||||
assert.Empty(t, input.Password)
|
assert.Empty(t, input.Password)
|
||||||
|
assert.True(t, input.AutoRemove)
|
||||||
|
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With AutoRemove the daemon reaps the container on exit, so act must not remove it afterwards.
|
|
||||||
func TestStepDockerAutoRemove(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 }
|
|
||||||
|
|
||||||
sd := &stepDocker{
|
|
||||||
RunContext: &RunContext{
|
|
||||||
Config: &Config{AutoRemove: tc.autoRemove},
|
|
||||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
|
||||||
JobContainer: cm,
|
|
||||||
},
|
|
||||||
Step: &model.Step{ID: "1", Uses: "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, sd.runUsesContainer()(context.Background()))
|
|
||||||
cm.AssertExpectations(t)
|
|
||||||
assert.Equal(t, tc.removes, removes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -199,23 +145,12 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
_ = sd.newStepContainer(ctx, "node:14", []string{"echo", "hi"}, nil)
|
newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
|
||||||
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
|
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStepDockerPrePost(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
sd := &stepDocker{}
|
|
||||||
|
|
||||||
err := sd.pre()(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
err = sd.post()(ctx)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -276,10 +211,10 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx),
|
require.NoError(t, sd.RunContext.resolvePlatformImage(ctx))
|
||||||
"IsHostEnv mismatch for platform %q", tc.platform)
|
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform)
|
||||||
|
|
||||||
_ = sd.newStepContainer(ctx, "alpine:3.20", []string{"echo", "hello"}, nil)
|
newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
|
||||||
|
|
||||||
if tc.expectDefault {
|
if tc.expectDefault {
|
||||||
assert.Equal(t, "default", captured.NetworkMode,
|
assert.Equal(t, "default", captured.NetworkMode,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
@@ -19,7 +22,7 @@ type stepFactoryImpl struct{}
|
|||||||
func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step, error) {
|
func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step, error) {
|
||||||
switch stepModel.Type() {
|
switch stepModel.Type() {
|
||||||
case model.StepTypeInvalid:
|
case model.StepTypeInvalid:
|
||||||
return nil, fmt.Errorf("Invalid run/uses syntax for job:%s step:%+v", rc.Run, stepModel)
|
return nil, fmt.Errorf("invalid run/uses syntax for job:%s step:%+v", rc.Run, stepModel)
|
||||||
case model.StepTypeRun:
|
case model.StepTypeRun:
|
||||||
return &stepRun{
|
return &stepRun{
|
||||||
Step: stepModel,
|
Step: stepModel,
|
||||||
@@ -33,6 +36,18 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step,
|
|||||||
runAction: runActionImpl,
|
runAction: runActionImpl,
|
||||||
}, nil
|
}, nil
|
||||||
case model.StepTypeUsesActionRemote:
|
case model.StepTypeUsesActionRemote:
|
||||||
|
if name, ok := strings.CutPrefix(stepModel.Uses, "builtin:"); ok {
|
||||||
|
run, ok := builtinActions[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown built-in action %q (known: %v) for job:%s step:%+v",
|
||||||
|
name, slices.Sorted(maps.Keys(builtinActions)), rc.Run, stepModel)
|
||||||
|
}
|
||||||
|
return &stepActionBuiltin{
|
||||||
|
Step: stepModel,
|
||||||
|
RunContext: rc,
|
||||||
|
run: run,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
return &stepActionRemote{
|
return &stepActionRemote{
|
||||||
Step: stepModel,
|
Step: stepModel,
|
||||||
RunContext: rc,
|
RunContext: rc,
|
||||||
@@ -46,5 +61,5 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("Unable to determine how to run job:%s step:%+v", rc.Run, stepModel)
|
return nil, fmt.Errorf("unable to determine how to run job:%s step:%+v", rc.Run, stepModel)
|
||||||
}
|
}
|
||||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user