mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-06-09 18:44:23 +02:00
Compare commits
5 Commits
v1.0.8
...
dafb880cae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dafb880cae | ||
|
|
ded278da71 | ||
|
|
fe73bf9a96 | ||
|
|
f9bfeb85d9 | ||
|
|
81f3d3ef3f |
@@ -40,7 +40,7 @@ cpu.out
|
|||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
/gitea-runner
|
/runner
|
||||||
/debug
|
/debug
|
||||||
|
|
||||||
/bin
|
/bin
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
name: pr-title
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- edited
|
|
||||||
- reopened
|
|
||||||
- synchronize
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-pr-title:
|
|
||||||
if: github.event.pull_request.draft == false
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
- run: make lint-pr-title
|
|
||||||
env:
|
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
args: release --nightly
|
args: release --nightly
|
||||||
@@ -57,13 +57,13 @@ 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@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
@@ -71,13 +71,8 @@ jobs:
|
|||||||
- name: Echo the tag
|
- name: Echo the tag
|
||||||
run: echo "${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}"
|
run: echo "${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}"
|
||||||
|
|
||||||
- name: Get Meta
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -88,5 +83,3 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
|
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
|
||||||
build-args: |
|
|
||||||
VERSION=${{ steps.meta.outputs.REPO_VERSION }}
|
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ jobs:
|
|||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
id: import_gpg
|
id: import_gpg
|
||||||
uses: crazy-max/ghaction-import-gpg@v7
|
uses: crazy-max/ghaction-import-gpg@v6
|
||||||
with:
|
with:
|
||||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
passphrase: ${{ secrets.PASSPHRASE }}
|
passphrase: ${{ secrets.PASSPHRASE }}
|
||||||
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
args: release
|
args: release
|
||||||
@@ -60,23 +60,28 @@ 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@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Repo Meta
|
||||||
|
id: repo_meta
|
||||||
|
run: |
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: "Docker meta"
|
- name: "Docker meta"
|
||||||
id: docker_meta
|
id: docker_meta
|
||||||
uses: docker/metadata-action@v6
|
uses: https://github.com/docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_ORG }}/runner
|
${{ env.DOCKER_ORG }}/${{ steps.repo_meta.outputs.REPO_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{major}}.{{minor}}.{{patch}}
|
type=semver,pattern={{major}}.{{minor}}.{{patch}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
@@ -86,7 +91,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@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -96,5 +101,3 @@ jobs:
|
|||||||
linux/arm64
|
linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
build-args: |
|
|
||||||
VERSION=${{ steps.docker_meta.outputs.version }}
|
|
||||||
|
|||||||
@@ -1,44 +1,20 @@
|
|||||||
name: checks
|
name: checks
|
||||||
on:
|
on:
|
||||||
push:
|
- push
|
||||||
branches:
|
- pull_request
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
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@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
- name: prepare anonymous docker config
|
|
||||||
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
|
||||||
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
|
|
||||||
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
|
|
||||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
|
||||||
- name: pre-pull test images
|
|
||||||
run: |
|
|
||||||
for img in node:24-bookworm-slim nginx:alpine; do
|
|
||||||
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
|
|
||||||
done
|
|
||||||
- name: lint
|
- name: lint
|
||||||
run: make lint
|
run: make lint
|
||||||
- name: build
|
- name: build
|
||||||
run: make build
|
run: make build
|
||||||
- name: test
|
- name: test
|
||||||
run: make test
|
run: make test
|
||||||
# Build the dind image and run the daemon-facing tests against the docker version it
|
|
||||||
# ships, catching daemon-level regressions (e.g. gitea/runner#981) before release. Runs
|
|
||||||
# after `make test` so the images it needs are already present on the host daemon.
|
|
||||||
- name: test against dind image
|
|
||||||
run: make test-dind
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,5 @@
|
|||||||
/gitea-runner
|
/runner
|
||||||
.env
|
.env
|
||||||
!/act/runner/testdata/secrets/.env
|
|
||||||
.runner
|
.runner
|
||||||
coverage.txt
|
coverage.txt
|
||||||
/config.yaml
|
/config.yaml
|
||||||
@@ -11,4 +10,4 @@ coverage.txt
|
|||||||
.vscode
|
.vscode
|
||||||
__debug_bin
|
__debug_bin
|
||||||
# gorelease binary folder
|
# gorelease binary folder
|
||||||
/dist
|
dist
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
project_name: gitea-runner
|
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
@@ -88,7 +86,7 @@ blobs:
|
|||||||
provider: s3
|
provider: s3
|
||||||
bucket: "{{ .Env.S3_BUCKET }}"
|
bucket: "{{ .Env.S3_BUCKET }}"
|
||||||
region: "{{ .Env.S3_REGION }}"
|
region: "{{ .Env.S3_REGION }}"
|
||||||
directory: "gitea-runner/{{.Version}}"
|
directory: "runner/{{.Version}}"
|
||||||
extra_files:
|
extra_files:
|
||||||
- glob: ./**.xz
|
- glob: ./**.xz
|
||||||
- glob: ./**.sha256
|
- glob: ./**.sha256
|
||||||
|
|||||||
30
Dockerfile
30
Dockerfile
@@ -1,7 +1,7 @@
|
|||||||
### BUILDER STAGE
|
### BUILDER STAGE
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM golang:1.26-alpine3.23 AS builder
|
FROM golang:1.26-alpine 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,16 +17,11 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.5.2-dind AS dind
|
FROM docker:28-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
|
||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
|
||||||
|
|
||||||
RUN apk add --no-cache s6 bash git tzdata
|
RUN apk add --no-cache s6 bash git tzdata
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/runner /usr/local/bin/runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
COPY scripts/s6 /etc/s6
|
COPY scripts/s6 /etc/s6
|
||||||
|
|
||||||
@@ -37,17 +32,12 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.5.2-dind-rootless AS dind-rootless
|
FROM docker:28-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
|
||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk add --no-cache s6 bash git tzdata
|
RUN apk add --no-cache s6 bash git tzdata
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/runner /usr/local/bin/runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
COPY scripts/s6 /etc/s6
|
COPY scripts/s6 /etc/s6
|
||||||
|
|
||||||
@@ -63,16 +53,10 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### BASIC VARIANT
|
### BASIC VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM alpine:3.23 AS basic
|
FROM alpine AS basic
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
|
||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
|
||||||
|
|
||||||
RUN apk add --no-cache tini bash git tzdata
|
RUN apk add --no-cache tini bash git tzdata
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/runner /usr/local/bin/runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|||||||
18
Makefile
18
Makefile
@@ -1,5 +1,5 @@
|
|||||||
DIST := dist
|
DIST := dist
|
||||||
EXECUTABLE := gitea-runner
|
EXECUTABLE := runner
|
||||||
DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
||||||
GO ?= go
|
GO ?= go
|
||||||
SHASUM ?= shasum -a 256
|
SHASUM ?= shasum -a 256
|
||||||
@@ -18,8 +18,8 @@ DOCKER_TAG ?= nightly
|
|||||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
|
||||||
|
|
||||||
STATIC ?=
|
STATIC ?=
|
||||||
EXTLDFLAGS ?=
|
EXTLDFLAGS ?=
|
||||||
@@ -118,10 +118,6 @@ lint-go: ## lint go files
|
|||||||
lint-go-fix: ## lint go files and fix issues
|
lint-go-fix: ## lint go files and fix issues
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
||||||
|
|
||||||
.PHONY: lint-pr-title
|
|
||||||
lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
|
||||||
@node ./tools/lint-pr-title.ts
|
|
||||||
|
|
||||||
.PHONY: security-check
|
.PHONY: security-check
|
||||||
security-check: deps-tools
|
security-check: deps-tools
|
||||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||||
@@ -140,12 +136,8 @@ tidy-check: tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
test: fmt-check security-check ## test everything
|
||||||
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
@$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||||
|
|
||||||
.PHONY: test-dind
|
|
||||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
|
||||||
@./scripts/test-dind.sh $(TARGET)
|
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: $(GOFILES) ## install the runner binary via `go install`
|
install: $(GOFILES) ## install the runner binary via `go install`
|
||||||
|
|||||||
59
README.md
59
README.md
@@ -8,7 +8,7 @@ Docker Engine Community version is required for docker mode. To install Docker C
|
|||||||
|
|
||||||
### Download pre-built binary
|
### Download pre-built binary
|
||||||
|
|
||||||
Visit [here](https://dl.gitea.com/gitea-runner/) and download the right version for your platform.
|
Visit [here](https://dl.gitea.com/runner/) and download the right version for your platform.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ ENABLED=true
|
|||||||
### Register
|
### Register
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gitea-runner register
|
./runner register
|
||||||
```
|
```
|
||||||
|
|
||||||
And you will be asked to input:
|
And you will be asked to input:
|
||||||
@@ -66,7 +66,7 @@ INFO Runner registered successfully.
|
|||||||
You can also register with command line arguments.
|
You can also register with command line arguments.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gitea-runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
|
./runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
|
||||||
```
|
```
|
||||||
|
|
||||||
If the registry succeed, it will run immediately. Next time, you could run the runner directly.
|
If the registry succeed, it will run immediately. Next time, you could run the runner directly.
|
||||||
@@ -74,7 +74,7 @@ If the registry succeed, it will run immediately. Next time, you could run the r
|
|||||||
### Run
|
### Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gitea-runner daemon
|
./runner daemon
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run with docker
|
### Run with docker
|
||||||
@@ -83,60 +83,23 @@ If the registry succeed, it will run immediately. Next time, you could run the r
|
|||||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:nightly
|
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:nightly
|
||||||
```
|
```
|
||||||
|
|
||||||
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
You can also configure the runner with a configuration file.
|
||||||
|
The configuration file is a YAML file, you can generate a sample configuration file with `./runner generate-config`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gitea-runner generate-config > config.yaml
|
./runner generate-config > config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`):
|
You can specify the configuration file path with `-c`/`--config` argument.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gitea-runner -c config.yaml register
|
./runner -c config.yaml register # register with config file
|
||||||
./gitea-runner -c config.yaml daemon
|
./runner -c config.yaml daemon # run with config file
|
||||||
./gitea-runner -c config.yaml cache-server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `generate-config` prints).
|
You can read the latest version of the configuration file online at [config.example.yaml](internal/pkg/config/config.example.yaml).
|
||||||
|
|
||||||
#### Without a config file
|
|
||||||
|
|
||||||
If you omit `-c`, built-in defaults apply (same as an empty YAML document). A small set of **deprecated** environment variables can still override parts of that default config, but **only when no `-c` path was given**; they are ignored if you use a config file:
|
|
||||||
|
|
||||||
| Variable | Effect |
|
|
||||||
| --- | --- |
|
|
||||||
| `GITEA_DEBUG` | If true, sets log level to `debug` |
|
|
||||||
| `GITEA_TRACE` | If true, sets log level to `trace` |
|
|
||||||
| `GITEA_RUNNER_CAPACITY` | Concurrent jobs (integer) |
|
|
||||||
| `GITEA_RUNNER_FILE` | Registration state file path (default `.runner`) |
|
|
||||||
| `GITEA_RUNNER_ENVIRON` | Extra job env vars as comma-separated `KEY:VALUE` pairs |
|
|
||||||
| `GITEA_RUNNER_ENV_FILE` | Path to an env file merged into job env (same idea as `runner.env_file` in YAML) |
|
|
||||||
|
|
||||||
Prefer a YAML file for all settings.
|
|
||||||
|
|
||||||
#### Registration vs config labels
|
|
||||||
|
|
||||||
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
|
|
||||||
|
|
||||||
#### External cache (`actions/cache`)
|
|
||||||
|
|
||||||
If `cache.external_server` is set, you must set `cache.external_secret` to the same value on this runner and on the standalone cache server. Run the server with `gitea-runner cache-server` using a config that defines `cache.external_secret` (and matching `cache.dir` / host / port as needed). Flags `--dir`, `--host`, and `--port` on `cache-server` override the file.
|
|
||||||
|
|
||||||
#### Official Docker image
|
|
||||||
|
|
||||||
Besides `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN`, the image entrypoint supports optional variables such as `CONFIG_FILE` (passed through as `-c`), `GITEA_RUNNER_LABELS`, `GITEA_RUNNER_EPHEMERAL`, `GITEA_RUNNER_ONCE`, `GITEA_RUNNER_NAME`, `GITEA_MAX_REG_ATTEMPTS`, `RUNNER_STATE_FILE`, and `GITEA_RUNNER_REGISTRATION_TOKEN_FILE`. See [scripts/run.sh](scripts/run.sh) for exact behavior.
|
|
||||||
|
|
||||||
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md).
|
|
||||||
|
|
||||||
When `container.bind_workdir` is enabled, stale task workspace directories can be cleaned while the runner is idle:
|
|
||||||
- directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
|
||||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable)
|
|
||||||
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
|
||||||
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
|
||||||
|
|
||||||
### Example Deployments
|
### Example Deployments
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,17 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -35,36 +28,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
apiPath = "/_apis/artifactcache"
|
urlBase = "/_apis/artifactcache"
|
||||||
internalPath = "/_internal"
|
|
||||||
|
|
||||||
// artifactURLTTL bounds how long a signed artifactLocation URL stays valid.
|
|
||||||
// Short enough that a leaked URL is near-worthless; long enough to let the
|
|
||||||
// @actions/cache client download a big blob that was returned from /cache.
|
|
||||||
artifactURLTTL = 10 * time.Minute
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type credKey struct{}
|
|
||||||
|
|
||||||
// JobCredential ties a per-job bearer token (ACTIONS_RUNTIME_TOKEN) to the
|
|
||||||
// repository that owns it. Every cache entry is stamped with Repo on
|
|
||||||
// reserve/commit and checked on read/write so one repo can never observe or
|
|
||||||
// poison another repo's cache, even from inside a container that reaches the
|
|
||||||
// cache server over the docker bridge network.
|
|
||||||
type JobCredential struct {
|
|
||||||
Repo string
|
|
||||||
}
|
|
||||||
|
|
||||||
// credEntry holds a registered job's credential along with an active
|
|
||||||
// registration count. RegisterJob is reference-counted so that if two tasks
|
|
||||||
// briefly share an ACTIONS_RUNTIME_TOKEN — e.g. a runner that retries a task
|
|
||||||
// after a crash before the old registration is revoked — the first task's
|
|
||||||
// revoker does not cut the second task's auth out from under it.
|
|
||||||
type credEntry struct {
|
|
||||||
cred JobCredential
|
|
||||||
refs int
|
|
||||||
}
|
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
dir string
|
dir string
|
||||||
storage *Storage
|
storage *Storage
|
||||||
@@ -77,36 +43,10 @@ type Handler struct {
|
|||||||
gcAt time.Time
|
gcAt time.Time
|
||||||
|
|
||||||
outboundIP string
|
outboundIP string
|
||||||
|
|
||||||
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
|
||||||
// runner can use these endpoints to pre-register per-job
|
|
||||||
// ACTIONS_RUNTIME_TOKENs against this server, enabling the same
|
|
||||||
// per-job auth and repo scoping as the embedded handler over the
|
|
||||||
// network. Empty disables the control-plane entirely.
|
|
||||||
internalSecret string
|
|
||||||
|
|
||||||
// secret signs short-lived artifact download URLs. The @actions/cache
|
|
||||||
// toolkit does not send Authorization on the download request, so blob
|
|
||||||
// GETs authenticate via a per-URL HMAC signature with expiry rather than
|
|
||||||
// via the bearer token used for management endpoints.
|
|
||||||
secret []byte
|
|
||||||
|
|
||||||
credMu sync.RWMutex
|
|
||||||
creds map[string]*credEntry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartHandler opens the on-disk cache store and starts the HTTP server.
|
func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger) (*Handler, error) {
|
||||||
//
|
h := &Handler{}
|
||||||
// internalSecret, when non-empty, enables a control-plane API at
|
|
||||||
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
|
||||||
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
|
|
||||||
// embedded in-process handler leaves it empty and registers tokens via the
|
|
||||||
// in-process RegisterJob method directly.
|
|
||||||
func StartHandler(dir, outboundIP string, port uint16, internalSecret string, logger logrus.FieldLogger) (*Handler, error) {
|
|
||||||
h := &Handler{
|
|
||||||
creds: make(map[string]*credEntry),
|
|
||||||
internalSecret: internalSecret,
|
|
||||||
}
|
|
||||||
|
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
discard := logrus.New()
|
discard := logrus.New()
|
||||||
@@ -143,37 +83,19 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
|
|||||||
h.outboundIP = ip.String()
|
h.outboundIP = ip.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
secret, err := loadOrCreateSecret(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
h.secret = secret
|
|
||||||
|
|
||||||
router := httprouter.New()
|
router := httprouter.New()
|
||||||
router.GET(apiPath+"/cache", h.bearerAuth(h.find))
|
router.GET(urlBase+"/cache", h.middleware(h.find))
|
||||||
router.POST(apiPath+"/caches", h.bearerAuth(h.reserve))
|
router.POST(urlBase+"/caches", h.middleware(h.reserve))
|
||||||
router.PATCH(apiPath+"/caches/:id", h.bearerAuth(h.upload))
|
router.PATCH(urlBase+"/caches/:id", h.middleware(h.upload))
|
||||||
router.POST(apiPath+"/caches/:id", h.bearerAuth(h.commit))
|
router.POST(urlBase+"/caches/:id", h.middleware(h.commit))
|
||||||
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
router.GET(urlBase+"/artifacts/:id", h.middleware(h.get))
|
||||||
// Artifact GET is signed via query-string HMAC because @actions/cache
|
router.POST(urlBase+"/clean", h.middleware(h.clean))
|
||||||
// does not attach Authorization when downloading archiveLocation.
|
|
||||||
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
|
|
||||||
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
|
||||||
// cache API can authenticate them. Always wired so the routes exist; the
|
|
||||||
// handlers themselves 401 when internalSecret is unset.
|
|
||||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
|
||||||
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
|
||||||
|
|
||||||
h.router = router
|
h.router = router
|
||||||
|
|
||||||
h.gcCache()
|
h.gcCache()
|
||||||
|
|
||||||
// Listen on all interfaces. Binding to outboundIP only would give no real
|
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces
|
||||||
// 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
|
|
||||||
// routable from inside the container network. Authentication is enforced
|
|
||||||
// by the bearer middleware and per-repo scoping, not by reachability.
|
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -199,91 +121,6 @@ func (h *Handler) ExternalURL() string {
|
|||||||
h.listener.Addr().(*net.TCPAddr).Port)
|
h.listener.Addr().(*net.TCPAddr).Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterJob makes token a valid bearer credential for cache requests from
|
|
||||||
// the given repository and returns a function that removes it. The runner
|
|
||||||
// calls this at job start and defers the returned func so that the credential
|
|
||||||
// is only accepted while the job is running.
|
|
||||||
//
|
|
||||||
// Registrations are reference-counted: if a token is already registered, the
|
|
||||||
// existing repo is kept and the refcount is incremented. The entry is
|
|
||||||
// removed only when every revoker returned by RegisterJob has been called.
|
|
||||||
// This keeps a stray re-registration from silently revoking a live job.
|
|
||||||
func (h *Handler) RegisterJob(token, repo string) func() {
|
|
||||||
if h == nil || token == "" {
|
|
||||||
return func() {}
|
|
||||||
}
|
|
||||||
h.credMu.Lock()
|
|
||||||
if existing, ok := h.creds[token]; ok {
|
|
||||||
existing.refs++
|
|
||||||
} else {
|
|
||||||
h.creds[token] = &credEntry{
|
|
||||||
cred: JobCredential{Repo: repo},
|
|
||||||
refs: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.credMu.Unlock()
|
|
||||||
return func() {
|
|
||||||
h.credMu.Lock()
|
|
||||||
if entry, ok := h.creds[token]; ok {
|
|
||||||
entry.refs--
|
|
||||||
if entry.refs <= 0 {
|
|
||||||
delete(h.creds, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.credMu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RevokeJob explicitly revokes one registration of token, mirroring one call
|
|
||||||
// of the closure returned by RegisterJob. Used by the control-plane endpoint
|
|
||||||
// so a remote runner can revoke without holding the closure.
|
|
||||||
func (h *Handler) RevokeJob(token string) {
|
|
||||||
if h == nil || token == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.credMu.Lock()
|
|
||||||
if entry, ok := h.creds[token]; ok {
|
|
||||||
entry.refs--
|
|
||||||
if entry.refs <= 0 {
|
|
||||||
delete(h.creds, token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.credMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) lookupCredential(token string) (JobCredential, bool) {
|
|
||||||
h.credMu.RLock()
|
|
||||||
entry, ok := h.creds[token]
|
|
||||||
h.credMu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return JobCredential{}, false
|
|
||||||
}
|
|
||||||
return entry.cred, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadOrCreateSecret returns the 32-byte HMAC signing key for artifact URLs,
|
|
||||||
// persisted in dir/.secret so signed URLs handed out before a restart stay
|
|
||||||
// valid across the restart and so the standalone cache-server can be pointed
|
|
||||||
// at by config.Cache.ExternalServer without the URL rotating.
|
|
||||||
func loadOrCreateSecret(dir string) ([]byte, error) {
|
|
||||||
path := filepath.Join(dir, ".secret")
|
|
||||||
if data, err := os.ReadFile(path); err == nil {
|
|
||||||
if secret, err := hex.DecodeString(strings.TrimSpace(string(data))); err == nil && len(secret) >= 32 {
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return nil, fmt.Errorf("read cache secret: %w", err)
|
|
||||||
}
|
|
||||||
secret := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(secret); err != nil {
|
|
||||||
return nil, fmt.Errorf("generate cache secret: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte(hex.EncodeToString(secret)), 0o600); err != nil {
|
|
||||||
return nil, fmt.Errorf("write cache secret: %w", err)
|
|
||||||
}
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Close() error {
|
func (h *Handler) Close() error {
|
||||||
if h == nil {
|
if h == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -323,8 +160,11 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
|
|||||||
|
|
||||||
// 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())
|
|
||||||
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
||||||
|
// cache keys are case insensitive
|
||||||
|
for i, key := range keys {
|
||||||
|
keys[i] = strings.ToLower(key)
|
||||||
|
}
|
||||||
version := r.URL.Query().Get("version")
|
version := r.URL.Query().Get("version")
|
||||||
|
|
||||||
db, err := h.openDB()
|
db, err := h.openDB()
|
||||||
@@ -334,7 +174,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := findCache(db, cred.Repo, keys, version)
|
cache, err := findCache(db, keys, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
return
|
||||||
@@ -354,22 +194,22 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
|
|||||||
}
|
}
|
||||||
h.responseJSON(w, r, 200, map[string]any{
|
h.responseJSON(w, r, 200, map[string]any{
|
||||||
"result": "hit",
|
"result": "hit",
|
||||||
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
"archiveLocation": fmt.Sprintf("%s%s/artifacts/%d", h.ExternalURL(), urlBase, cache.ID),
|
||||||
"cacheKey": cache.Key,
|
"cacheKey": cache.Key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/caches
|
// POST /_apis/artifactcache/caches
|
||||||
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())
|
|
||||||
api := &Request{}
|
api := &Request{}
|
||||||
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// cache keys are case insensitive
|
||||||
|
api.Key = strings.ToLower(api.Key)
|
||||||
|
|
||||||
cache := api.ToCache()
|
cache := api.ToCache()
|
||||||
cache.Repo = cred.Repo
|
|
||||||
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)
|
||||||
@@ -391,7 +231,6 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
|
|||||||
|
|
||||||
// PATCH /_apis/artifactcache/caches/:id
|
// PATCH /_apis/artifactcache/caches/:id
|
||||||
func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
cred := credFromContext(r.Context())
|
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
@@ -414,11 +253,6 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.Repo != cred.Repo {
|
|
||||||
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if cache.Complete {
|
if cache.Complete {
|
||||||
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||||
return
|
return
|
||||||
@@ -431,7 +265,6 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
}
|
}
|
||||||
if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
|
if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
h.useCache(id)
|
h.useCache(id)
|
||||||
h.responseJSON(w, r, 200)
|
h.responseJSON(w, r, 200)
|
||||||
@@ -439,7 +272,6 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
|
|
||||||
// POST /_apis/artifactcache/caches/:id
|
// POST /_apis/artifactcache/caches/:id
|
||||||
func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
cred := credFromContext(r.Context())
|
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
@@ -462,11 +294,6 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.Repo != cred.Repo {
|
|
||||||
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if cache.Complete {
|
if cache.Complete {
|
||||||
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
|
||||||
return
|
return
|
||||||
@@ -499,10 +326,6 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /_apis/artifactcache/artifacts/:id
|
// GET /_apis/artifactcache/artifacts/:id
|
||||||
// Authenticated via signed URL (see signedURLAuth), not bearer, because the
|
|
||||||
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
|
||||||
// Repository scoping is already enforced at find() time; the signature binds
|
|
||||||
// the URL to the specific cache ID and an expiry.
|
|
||||||
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -521,158 +344,21 @@ func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Par
|
|||||||
h.responseJSON(w, r, 200)
|
h.responseJSON(w, r, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bearerAuth resolves ACTIONS_RUNTIME_TOKEN against the set of currently
|
func (h *Handler) middleware(handler httprouter.Handle) httprouter.Handle {
|
||||||
// registered jobs. A match attaches the job's JobCredential to the request
|
|
||||||
// context; a miss returns 401 before the handler body runs.
|
|
||||||
func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
h.logger.Debugf("%s %s", r.Method, r.RequestURI)
|
||||||
token := bearerToken(r)
|
|
||||||
if token == "" {
|
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cred, ok := h.lookupCredential(token)
|
|
||||||
if !ok {
|
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
|
||||||
handler(w, r.WithContext(ctx), params)
|
|
||||||
go h.gcCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
h.responseJSON(w, r, 400, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expStr := r.URL.Query().Get("exp")
|
|
||||||
sig := r.URL.Query().Get("sig")
|
|
||||||
if expStr == "" || sig == "" {
|
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing signature"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("invalid expiry"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if time.Now().Unix() > exp {
|
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expected := h.computeSignature(id, exp)
|
|
||||||
if !hmac.Equal([]byte(sig), []byte(expected)) {
|
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handler(w, r, params)
|
handler(w, r, params)
|
||||||
go h.gcCache()
|
go h.gcCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// internalAuth gates the control-plane endpoints. The bearer must
|
|
||||||
// constant-time-equal the configured internalSecret. If the secret is empty,
|
|
||||||
// the control-plane is disabled and every request gets 404 — which matches
|
|
||||||
// the upstream nektos/act behavior of "the route does not exist".
|
|
||||||
func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
|
||||||
if h.internalSecret == "" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := bearerToken(r)
|
|
||||||
if token == "" || !hmac.Equal([]byte(token), []byte(h.internalSecret)) {
|
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handler(w, r, params)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type internalRegisterBody struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
Repo string `json:"repo"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type internalRevokeBody struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /_internal/register
|
|
||||||
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|
||||||
var body internalRegisterBody
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Token == "" {
|
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.RegisterJob(body.Token, body.Repo)
|
|
||||||
h.responseJSON(w, r, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /_internal/revoke
|
|
||||||
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
|
||||||
var body internalRevokeBody
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Token == "" {
|
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.RevokeJob(body.Token)
|
|
||||||
h.responseJSON(w, r, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bearerToken(r *http.Request) string {
|
|
||||||
auth := r.Header.Get("Authorization")
|
|
||||||
const prefix = "Bearer "
|
|
||||||
if len(auth) > len(prefix) && strings.EqualFold(auth[:len(prefix)], prefix) {
|
|
||||||
return auth[len(prefix):]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func credFromContext(ctx context.Context) JobCredential {
|
|
||||||
if cred, ok := ctx.Value(credKey{}).(JobCredential); ok {
|
|
||||||
return cred
|
|
||||||
}
|
|
||||||
return JobCredential{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) computeSignature(cacheID, exp int64) string {
|
|
||||||
mac := hmac.New(sha256.New, h.secret)
|
|
||||||
fmt.Fprintf(mac, "%d:%d", cacheID, exp)
|
|
||||||
return hex.EncodeToString(mac.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
|
|
||||||
expUnix := exp.Unix()
|
|
||||||
sig := h.computeSignature(int64(cacheID), expUnix)
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("exp", strconv.FormatInt(expUnix, 10))
|
|
||||||
q.Set("sig", sig)
|
|
||||||
return fmt.Sprintf("%s%s/artifacts/%d?%s", h.ExternalURL(), apiPath, cacheID, q.Encode())
|
|
||||||
}
|
|
||||||
|
|
||||||
// if not found, return (nil, nil) instead of an error.
|
// if not found, return (nil, nil) instead of an error.
|
||||||
func findCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
|
func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error) {
|
||||||
cache := &Cache{}
|
cache := &Cache{}
|
||||||
for _, prefix := range keys {
|
for _, prefix := range keys {
|
||||||
// if a key in the list matches exactly, don't return partial matches
|
// if a key in the list matches exactly, don't return partial matches
|
||||||
if err := db.FindOne(cache,
|
if err := db.FindOne(cache,
|
||||||
bolthold.Where("Repo").Eq(repo).
|
bolthold.Where("Key").Eq(prefix).
|
||||||
And("Key").Eq(prefix).
|
|
||||||
And("Version").Eq(version).
|
And("Version").Eq(version).
|
||||||
And("Complete").Eq(true).
|
And("Complete").Eq(true).
|
||||||
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
|
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
|
||||||
@@ -687,8 +373,7 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := db.FindOne(cache,
|
if err := db.FindOne(cache,
|
||||||
bolthold.Where("Repo").Eq(repo).
|
bolthold.Where("Key").RegExp(re).
|
||||||
And("Key").RegExp(re).
|
|
||||||
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 {
|
||||||
@@ -734,6 +419,7 @@ const (
|
|||||||
keepOld = 5 * time.Minute
|
keepOld = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func (h *Handler) gcCache() {
|
func (h *Handler) gcCache() {
|
||||||
if h.gcing.Load() {
|
if h.gcing.Load() {
|
||||||
return
|
return
|
||||||
@@ -808,16 +494,12 @@ func (h *Handler) gcCache() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the old caches with the same key and version within the same
|
// Remove the old caches with the same key and version, keep the latest one.
|
||||||
// repository, keep the latest one. Aggregation must include Repo so two
|
|
||||||
// repos that happen to share a (key, version) do not evict each other —
|
|
||||||
// otherwise per-repo scoping holds for reads but one repo can age
|
|
||||||
// another out after keepOld.
|
|
||||||
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
|
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
|
||||||
if results, err := db.FindAggregate(
|
if results, err := db.FindAggregate(
|
||||||
&Cache{},
|
&Cache{},
|
||||||
bolthold.Where("Complete").Eq(true),
|
bolthold.Where("Complete").Eq(true),
|
||||||
"Repo", "Key", "Version",
|
"Key", "Version",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
h.logger.Warnf("find aggregate caches: %v", err)
|
h.logger.Warnf("find aggregate caches: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -851,7 +533,7 @@ func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int,
|
|||||||
if len(v) == 0 || v[0] == nil {
|
if len(v) == 0 || v[0] == nil {
|
||||||
data, _ = json.Marshal(struct{}{})
|
data, _ = json.Marshal(struct{}{})
|
||||||
} else if err, ok := v[0].(error); ok {
|
} else if err, ok := v[0].(error); ok {
|
||||||
h.logger.Errorf("%v %v: %v", r.Method, r.URL.Path, err)
|
h.logger.Errorf("%v %v: %v", r.Method, r.RequestURI, err)
|
||||||
data, _ = json.Marshal(map[string]any{
|
data, _ = json.Marshal(map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -23,38 +22,12 @@ import (
|
|||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testToken is registered with the cache server in every test that needs to
|
|
||||||
// make authenticated requests; testClient then attaches it as the
|
|
||||||
// Authorization: Bearer header. testRepo is the repository scope used when
|
|
||||||
// registering it; cross-repo isolation is exercised in its own test.
|
|
||||||
const (
|
|
||||||
testToken = "test-runtime-token"
|
|
||||||
testRepo = "owner/repo"
|
|
||||||
)
|
|
||||||
|
|
||||||
type bearerTransport struct{ token string }
|
|
||||||
|
|
||||||
func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
||||||
r.Header.Set("Authorization", "Bearer "+b.token)
|
|
||||||
return http.DefaultTransport.RoundTrip(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
var testClient = &http.Client{Transport: &bearerTransport{token: testToken}}
|
|
||||||
|
|
||||||
// signArtifactURL builds a signed download URL the same way the server does;
|
|
||||||
// tests use it to reach the get handler directly without going through a
|
|
||||||
// find/cache-hit round trip.
|
|
||||||
func signArtifactURL(h *Handler, id int64) string {
|
|
||||||
return h.signedArtifactURL(uint64(id), time.Now().Add(artifactURLTTL))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandler(t *testing.T) {
|
func TestHandler(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(dir, "", 0, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
handler.RegisterJob(testToken, testRepo)
|
|
||||||
|
|
||||||
base := fmt.Sprintf("%s%s", handler.ExternalURL(), apiPath)
|
base := fmt.Sprintf("%s%s", handler.ExternalURL(), urlBase)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
t.Run("inpect db", func(t *testing.T) {
|
t.Run("inpect db", func(t *testing.T) {
|
||||||
@@ -72,10 +45,7 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, handler.Close())
|
require.NoError(t, handler.Close())
|
||||||
assert.Nil(t, handler.server)
|
assert.Nil(t, handler.server)
|
||||||
assert.Nil(t, handler.listener)
|
assert.Nil(t, handler.listener)
|
||||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil)
|
_, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
if err == nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
@@ -83,9 +53,8 @@ func TestHandler(t *testing.T) {
|
|||||||
t.Run("get not exist", func(t *testing.T) {
|
t.Run("get not exist", func(t *testing.T) {
|
||||||
key := strings.ToLower(t.Name())
|
key := strings.ToLower(t.Name())
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 204, resp.StatusCode)
|
require.Equal(t, 204, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -99,18 +68,16 @@ func TestHandler(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("clean", func(t *testing.T) {
|
t.Run("clean", func(t *testing.T) {
|
||||||
resp, err := testClient.Post(base+"/clean", "", nil)
|
resp, err := http.Post(base+"/clean", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("reserve with bad request", func(t *testing.T) {
|
t.Run("reserve with bad request", func(t *testing.T) {
|
||||||
body := []byte(`invalid json`)
|
body := []byte(`invalid json`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 400, resp.StatusCode)
|
assert.Equal(t, 400, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,9 +94,8 @@ func TestHandler(t *testing.T) {
|
|||||||
Size: 100,
|
Size: 100,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
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.NewDecoder(resp.Body).Decode(&first))
|
||||||
@@ -142,9 +108,8 @@ func TestHandler(t *testing.T) {
|
|||||||
Size: 100,
|
Size: 100,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
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.NewDecoder(resp.Body).Decode(&second))
|
||||||
@@ -160,9 +125,8 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||||
resp, err := testClient.Do(req)
|
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 400, resp.StatusCode)
|
assert.Equal(t, 400, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -172,9 +136,8 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||||
resp, err := testClient.Do(req)
|
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 400, resp.StatusCode)
|
assert.Equal(t, 400, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -192,9 +155,8 @@ func TestHandler(t *testing.T) {
|
|||||||
Size: 100,
|
Size: 100,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
got := struct {
|
got := struct {
|
||||||
@@ -209,15 +171,13 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||||
resp, err := testClient.Do(req)
|
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
|
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -226,9 +186,8 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||||
resp, err := testClient.Do(req)
|
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 400, resp.StatusCode)
|
assert.Equal(t, 400, resp.StatusCode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -247,9 +206,8 @@ func TestHandler(t *testing.T) {
|
|||||||
Size: 100,
|
Size: 100,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
got := struct {
|
got := struct {
|
||||||
@@ -264,27 +222,24 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
req.Header.Set("Content-Range", "bytes xx-99/*")
|
req.Header.Set("Content-Range", "bytes xx-99/*")
|
||||||
resp, err := testClient.Do(req)
|
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 400, resp.StatusCode)
|
assert.Equal(t, 400, resp.StatusCode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("commit with bad id", func(t *testing.T) {
|
t.Run("commit with bad id", func(t *testing.T) {
|
||||||
{
|
{
|
||||||
resp, err := testClient.Post(base+"/caches/invalid_id", "", nil)
|
resp, err := http.Post(base+"/caches/invalid_id", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 400, resp.StatusCode)
|
assert.Equal(t, 400, resp.StatusCode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("commit with not exist id", func(t *testing.T) {
|
t.Run("commit with not exist id", func(t *testing.T) {
|
||||||
{
|
{
|
||||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil)
|
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 400, resp.StatusCode)
|
assert.Equal(t, 400, resp.StatusCode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -303,9 +258,8 @@ func TestHandler(t *testing.T) {
|
|||||||
Size: 100,
|
Size: 100,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
got := struct {
|
got := struct {
|
||||||
@@ -320,73 +274,22 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||||
resp, err := testClient.Do(req)
|
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
|
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
|
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 400, resp.StatusCode)
|
assert.Equal(t, 400, resp.StatusCode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("upload write failure returns only error", func(t *testing.T) {
|
|
||||||
key := strings.ToLower(t.Name())
|
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
|
||||||
var id uint64
|
|
||||||
{
|
|
||||||
body, err := json.Marshal(&Request{
|
|
||||||
Key: key,
|
|
||||||
Version: version,
|
|
||||||
Size: 100,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 200, resp.StatusCode)
|
|
||||||
|
|
||||||
got := struct {
|
|
||||||
CacheID uint64 `json:"cacheId"`
|
|
||||||
}{}
|
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
|
||||||
id = got.CacheID
|
|
||||||
}
|
|
||||||
|
|
||||||
storageFile := filepath.Join(dir, "not-a-directory")
|
|
||||||
require.NoError(t, os.WriteFile(storageFile, []byte("blocked"), 0o600))
|
|
||||||
originalStorage := handler.storage
|
|
||||||
handler.storage = &Storage{rootDir: storageFile}
|
|
||||||
defer func() {
|
|
||||||
handler.storage = originalStorage
|
|
||||||
}()
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPatch,
|
|
||||||
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(make([]byte, 100)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
|
||||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
|
||||||
resp, err := testClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 500, resp.StatusCode)
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
var got map[string]string
|
|
||||||
require.NoError(t, json.Unmarshal(body, &got))
|
|
||||||
assert.NotEmpty(t, got["error"])
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("commit early", func(t *testing.T) {
|
t.Run("commit early", func(t *testing.T) {
|
||||||
key := strings.ToLower(t.Name())
|
key := strings.ToLower(t.Name())
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
@@ -401,9 +304,8 @@ func TestHandler(t *testing.T) {
|
|||||||
Size: 100,
|
Size: 100,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
got := struct {
|
got := struct {
|
||||||
@@ -418,37 +320,32 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
req.Header.Set("Content-Range", "bytes 0-59/*")
|
req.Header.Set("Content-Range", "bytes 0-59/*")
|
||||||
resp, err := testClient.Do(req)
|
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
|
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 500, resp.StatusCode)
|
assert.Equal(t, 500, resp.StatusCode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("get with bad id", func(t *testing.T) {
|
t.Run("get with bad id", func(t *testing.T) {
|
||||||
resp, err := testClient.Get(base + "/artifacts/invalid_id")
|
resp, err := http.Get(base + "/artifacts/invalid_id") //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 400, resp.StatusCode)
|
require.Equal(t, 400, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("get with not exist id", func(t *testing.T) {
|
t.Run("get with not exist id", func(t *testing.T) {
|
||||||
resp, err := testClient.Get(signArtifactURL(handler, 100))
|
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 404, resp.StatusCode)
|
require.Equal(t, 404, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("get with not exist id", func(t *testing.T) {
|
t.Run("get with not exist id", func(t *testing.T) {
|
||||||
resp, err := testClient.Get(signArtifactURL(handler, 100))
|
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 404, resp.StatusCode)
|
require.Equal(t, 404, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -478,9 +375,8 @@ func TestHandler(t *testing.T) {
|
|||||||
key + "_a",
|
key + "_a",
|
||||||
}, ",")
|
}, ",")
|
||||||
|
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
|
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 200, resp.StatusCode)
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -499,31 +395,26 @@ func TestHandler(t *testing.T) {
|
|||||||
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)
|
||||||
|
|
||||||
contentResp, err := testClient.Get(got.ArchiveLocation)
|
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer contentResp.Body.Close()
|
|
||||||
require.Equal(t, 200, contentResp.StatusCode)
|
require.Equal(t, 200, contentResp.StatusCode)
|
||||||
content, err := io.ReadAll(contentResp.Body)
|
content, err := io.ReadAll(contentResp.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, contents[except], content)
|
assert.Equal(t, contents[except], content)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("case preserved", func(t *testing.T) {
|
t.Run("case insensitive", func(t *testing.T) {
|
||||||
// Some actions (e.g. actions/setup-go, actions/setup-node) build cache keys that contain mixed-case fragments such as RUNNER_OS=Linux,
|
|
||||||
// then compare the cacheKey returned by the cache server to their original key with case-sensitive equality to decide whether the
|
|
||||||
// cache was a complete hit. The server must therefore preserve the original key case.
|
|
||||||
|
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
key := strings.ToLower(t.Name()) + "_ABC"
|
key := strings.ToLower(t.Name())
|
||||||
content := make([]byte, 100)
|
content := make([]byte, 100)
|
||||||
_, err := rand.Read(content)
|
_, err := rand.Read(content)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, key, version, content)
|
uploadCacheNormally(t, base, key+"_ABC", version, content)
|
||||||
|
|
||||||
{
|
{
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
reqKey := key + "_aBc"
|
||||||
|
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 200, resp.StatusCode)
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
got := struct {
|
got := struct {
|
||||||
Result string `json:"result"`
|
Result string `json:"result"`
|
||||||
@@ -532,8 +423,7 @@ func TestHandler(t *testing.T) {
|
|||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, "hit", got.Result)
|
assert.Equal(t, "hit", got.Result)
|
||||||
assert.Equal(t, key, got.CacheKey)
|
assert.Equal(t, key+"_abc", got.CacheKey)
|
||||||
assert.NotEqual(t, strings.ToLower(key), got.CacheKey)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -562,9 +452,8 @@ func TestHandler(t *testing.T) {
|
|||||||
key + "_a_b",
|
key + "_a_b",
|
||||||
}, ",")
|
}, ",")
|
||||||
|
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
|
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 200, resp.StatusCode)
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -581,9 +470,8 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, keys[expect], got.CacheKey)
|
assert.Equal(t, keys[expect], got.CacheKey)
|
||||||
|
|
||||||
contentResp, err := testClient.Get(got.ArchiveLocation)
|
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer contentResp.Body.Close()
|
|
||||||
require.Equal(t, 200, contentResp.StatusCode)
|
require.Equal(t, 200, contentResp.StatusCode)
|
||||||
content, err := io.ReadAll(contentResp.Body)
|
content, err := io.ReadAll(contentResp.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -616,9 +504,8 @@ func TestHandler(t *testing.T) {
|
|||||||
key + "_a_b",
|
key + "_a_b",
|
||||||
}, ",")
|
}, ",")
|
||||||
|
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
|
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 200, resp.StatusCode)
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -636,9 +523,8 @@ func TestHandler(t *testing.T) {
|
|||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, keys[expect], got.CacheKey)
|
assert.Equal(t, keys[expect], got.CacheKey)
|
||||||
|
|
||||||
contentResp, err := testClient.Get(got.ArchiveLocation)
|
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer contentResp.Body.Close()
|
|
||||||
require.Equal(t, 200, contentResp.StatusCode)
|
require.Equal(t, 200, contentResp.StatusCode)
|
||||||
content, err := io.ReadAll(contentResp.Body)
|
content, err := io.ReadAll(contentResp.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -655,9 +541,8 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
|||||||
Size: int64(len(content)),
|
Size: int64(len(content)),
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
got := struct {
|
got := struct {
|
||||||
@@ -672,22 +557,19 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
req.Header.Set("Content-Range", "bytes 0-99/*")
|
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||||
resp, err := testClient.Do(req)
|
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
|
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
assert.Equal(t, 200, resp.StatusCode)
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
}
|
}
|
||||||
var archiveLocation string
|
var archiveLocation string
|
||||||
{
|
{
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 200, resp.StatusCode)
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
got := struct {
|
got := struct {
|
||||||
Result string `json:"result"`
|
Result string `json:"result"`
|
||||||
@@ -696,13 +578,12 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
|||||||
}{}
|
}{}
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
assert.Equal(t, "hit", got.Result)
|
assert.Equal(t, "hit", got.Result)
|
||||||
assert.Equal(t, key, got.CacheKey)
|
assert.Equal(t, strings.ToLower(key), got.CacheKey)
|
||||||
archiveLocation = got.ArchiveLocation
|
archiveLocation = got.ArchiveLocation
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
resp, err := testClient.Get(archiveLocation)
|
resp, err := http.Get(archiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 200, resp.StatusCode)
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
got, err := io.ReadAll(resp.Body)
|
got, err := io.ReadAll(resp.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -712,7 +593,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
|||||||
|
|
||||||
func TestHandler_gcCache(t *testing.T) {
|
func TestHandler_gcCache(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
handler, err := StartHandler(dir, "", 0, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -818,421 +699,3 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandler_RejectsMissingBearer covers the advisory's root cause:
|
|
||||||
// unauthenticated access to management endpoints is now refused with 401.
|
|
||||||
func TestHandler_RejectsMissingBearer(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
body string
|
|
||||||
}{
|
|
||||||
{"find", http.MethodGet, "/cache?keys=x&version=y", ""},
|
|
||||||
{"reserve", http.MethodPost, "/caches", "{}"},
|
|
||||||
{"upload", http.MethodPatch, "/caches/1", ""},
|
|
||||||
{"commit", http.MethodPost, "/caches/1", ""},
|
|
||||||
{"clean", http.MethodPost, "/clean", ""},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req, err := http.NewRequest(tc.method, base+tc.path, strings.NewReader(tc.body))
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_RejectsUnknownBearer verifies that a bearer token is only
|
|
||||||
// accepted after RegisterJob; stale/forged tokens cannot be replayed.
|
|
||||||
func TestHandler_RejectsUnknownBearer(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
req.Header.Set("Authorization", "Bearer not-a-registered-token")
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_UnregisterRevokes ensures that the function returned by
|
|
||||||
// RegisterJob invalidates the credential, so a token leaked at job time stops
|
|
||||||
// working the moment the job ends instead of living for the runner's lifetime.
|
|
||||||
func TestHandler_UnregisterRevokes(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
unregister := handler.RegisterJob("tmp-token", testRepo)
|
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
|
||||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
req.Header.Set("Authorization", "Bearer tmp-token")
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
|
|
||||||
unregister()
|
|
||||||
|
|
||||||
resp, err = http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_CrossRepoIsolation addresses the intra-runner poisoning vector
|
|
||||||
// raised in GHSA-82g9-637c-2fx2: job containers can reach the cache server
|
|
||||||
// over the docker bridge, so IP allowlisting alone does not stop a malicious
|
|
||||||
// PR run from another repo. A cache entry created under repoA must be
|
|
||||||
// invisible to queries scoped to repoB.
|
|
||||||
func TestHandler_CrossRepoIsolation(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
handler.RegisterJob("token-a", "owner/repoA")
|
|
||||||
handler.RegisterJob("token-b", "owner/repoB")
|
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
|
||||||
key := "shared-key"
|
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
|
||||||
content := []byte("repoA-payload")
|
|
||||||
|
|
||||||
clientA := &http.Client{Transport: &bearerTransport{token: "token-a"}}
|
|
||||||
clientB := &http.Client{Transport: &bearerTransport{token: "token-b"}}
|
|
||||||
|
|
||||||
// repoA reserves + uploads + commits.
|
|
||||||
reserveBody, err := json.Marshal(&Request{Key: key, Version: version, Size: int64(len(content))})
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp, err := clientA.Post(base+"/caches", "application/json", bytes.NewReader(reserveBody))
|
|
||||||
require.NoError(t, err)
|
|
||||||
var reserved struct {
|
|
||||||
CacheID uint64 `json:"cacheId"`
|
|
||||||
}
|
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&reserved))
|
|
||||||
resp.Body.Close()
|
|
||||||
require.NotZero(t, reserved.CacheID)
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(content))
|
|
||||||
require.NoError(t, err)
|
|
||||||
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
|
|
||||||
resp, err = clientA.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
resp, err = clientA.Post(fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
// repoB with a matching key and version must NOT see repoA's cache.
|
|
||||||
resp, err = clientB.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
|
||||||
|
|
||||||
// repoA still sees its own cache.
|
|
||||||
resp, err = clientA.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
// repoB cannot upload to repoA's reserved id either (forbidden, not 401).
|
|
||||||
req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader([]byte("poison")))
|
|
||||||
require.NoError(t, err)
|
|
||||||
req.Header.Set("Content-Range", "bytes 0-5/*")
|
|
||||||
resp, err = clientB.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_ArtifactSignature verifies that archive downloads reject
|
|
||||||
// missing / tampered / expired signatures, so a leaked archiveLocation stops
|
|
||||||
// working after artifactURLTTL even if the bearer token is still registered.
|
|
||||||
func TestHandler_ArtifactSignature(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
handler.RegisterJob(testToken, testRepo)
|
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
|
||||||
|
|
||||||
t.Run("missing signature", func(t *testing.T) {
|
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s/artifacts/%d", base, 1))
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("tampered signature", func(t *testing.T) {
|
|
||||||
good := handler.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
|
|
||||||
bad := good[:len(good)-4] + "dead"
|
|
||||||
resp, err := testClient.Get(bad)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("expired signature", func(t *testing.T) {
|
|
||||||
expired := handler.signedArtifactURL(1, time.Now().Add(-time.Second))
|
|
||||||
resp, err := testClient.Get(expired)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("signature from a different server", func(t *testing.T) {
|
|
||||||
dir2 := filepath.Join(t.TempDir(), "artifactcache2")
|
|
||||||
other, err := StartHandler(dir2, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer other.Close()
|
|
||||||
otherURL := other.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
|
|
||||||
// Rewrite the host so the request still lands on our handler, but
|
|
||||||
// the signature was computed with a different secret.
|
|
||||||
parts := strings.SplitN(otherURL, apiPath, 2)
|
|
||||||
forged := base + parts[1]
|
|
||||||
resp, err := testClient.Get(forged)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_SecretPersistsAcrossRestarts is the property that lets
|
|
||||||
// gitea-runner cache-server be pointed at via cfg.Cache.ExternalServer: a
|
|
||||||
// restart must not invalidate signed URLs the handler has already issued
|
|
||||||
// (within their expiry window).
|
|
||||||
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
|
|
||||||
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
exp := time.Now().Add(artifactURLTTL).Unix()
|
|
||||||
sig := first.computeSignature(42, exp)
|
|
||||||
require.NoError(t, first.Close())
|
|
||||||
|
|
||||||
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer second.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, sig, second.computeSignature(42, exp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that
|
|
||||||
// ensures a real reserve/upload/commit/find/download flow still works after
|
|
||||||
// the auth refactor.
|
|
||||||
func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
handler.RegisterJob(testToken, testRepo)
|
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
|
||||||
key := "download-key"
|
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
|
||||||
content := []byte("hello")
|
|
||||||
uploadCacheNormally(t, base, key, version, content)
|
|
||||||
|
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var hit struct {
|
|
||||||
ArchiveLocation string `json:"archiveLocation"`
|
|
||||||
}
|
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&hit))
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
require.Contains(t, hit.ArchiveLocation, "sig=")
|
|
||||||
require.Contains(t, hit.ArchiveLocation, "exp=")
|
|
||||||
|
|
||||||
// Download without any Authorization header — the signature alone must
|
|
||||||
// be enough, because @actions/cache downloads archiveLocation unauth'd.
|
|
||||||
dl, err := http.Get(hit.ArchiveLocation)
|
|
||||||
require.NoError(t, err)
|
|
||||||
body, err := io.ReadAll(dl.Body)
|
|
||||||
dl.Body.Close()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, http.StatusOK, dl.StatusCode)
|
|
||||||
assert.Equal(t, content, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_RegisterJob_RefCounted verifies that a duplicate RegisterJob
|
|
||||||
// for the same token does not silently revoke the first registration on the
|
|
||||||
// first revoker call. This matters if a runner ever re-registers a token
|
|
||||||
// (restart mid-task, retry), which must not kill the live job's auth.
|
|
||||||
func TestHandler_RegisterJob_RefCounted(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
first := handler.RegisterJob("shared", testRepo)
|
|
||||||
second := handler.RegisterJob("shared", testRepo)
|
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
|
||||||
probe := func() int {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
req.Header.Set("Authorization", "Bearer shared")
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
return resp.StatusCode
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NotEqual(t, http.StatusUnauthorized, probe())
|
|
||||||
first()
|
|
||||||
assert.NotEqual(t, http.StatusUnauthorized, probe(),
|
|
||||||
"token must stay valid while another registration holds the refcount")
|
|
||||||
second()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, probe(),
|
|
||||||
"token is revoked only after every revoker has run")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict
|
|
||||||
// another repo's entry. Two repos reserve the same (key, version); after the
|
|
||||||
// keepOld window, GC must keep the one from each repo.
|
|
||||||
func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
handler.RegisterJob("tok-a", "owner/repoA")
|
|
||||||
handler.RegisterJob("tok-b", "owner/repoB")
|
|
||||||
|
|
||||||
key := "shared-dedup-key"
|
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
|
||||||
|
|
||||||
// Seed one completed cache per repo directly via the DB, bypassing the
|
|
||||||
// HTTP round trip so we can precisely control UsedAt.
|
|
||||||
db, err := handler.openDB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
now := time.Now().Unix()
|
|
||||||
stale := time.Now().Add(-keepOld - time.Minute).Unix()
|
|
||||||
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
|
||||||
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
|
||||||
require.NoError(t, insertCache(db, a))
|
|
||||||
require.NoError(t, insertCache(db, b))
|
|
||||||
// Write the backing blobs so the dedup deletion has something to remove.
|
|
||||||
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
|
|
||||||
_, err = handler.storage.Commit(a.ID, 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, handler.storage.Write(b.ID, 0, strings.NewReader("b")))
|
|
||||||
_, err = handler.storage.Commit(b.ID, 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
// Force GC to run regardless of the cooldown.
|
|
||||||
handler.gcAt = time.Time{}
|
|
||||||
handler.gcCache()
|
|
||||||
|
|
||||||
db, err = handler.openDB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer db.Close()
|
|
||||||
var after []Cache
|
|
||||||
require.NoError(t, db.Find(&after, bolthold.Where("Key").Eq(key).And("Version").Eq(version)))
|
|
||||||
|
|
||||||
repos := make(map[string]bool)
|
|
||||||
for _, c := range after {
|
|
||||||
repos[c.Repo] = true
|
|
||||||
}
|
|
||||||
assert.True(t, repos["owner/repoA"], "repoA's cache must survive dedup against repoB")
|
|
||||||
assert.True(t, repos["owner/repoB"], "repoB's cache must survive dedup against repoA")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_InternalAPI_Disabled verifies that without an internalSecret
|
|
||||||
// the control-plane routes are 404 — operators can't accidentally hit
|
|
||||||
// register/revoke when the feature is off.
|
|
||||||
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(dir, "", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
for _, ep := range []string{"/_internal/register", "/_internal/revoke"} {
|
|
||||||
resp, err := http.Post(handler.ExternalURL()+ep, "application/json", strings.NewReader(`{}`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode, ep)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandler_InternalAPI_AuthAndUsage covers the control-plane: bad/missing
|
|
||||||
// secret → 401, malformed body → 400, happy path round-trips a token through
|
|
||||||
// register → cache-API accepts it → revoke → cache-API rejects it.
|
|
||||||
func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
const secret = "internal-secret"
|
|
||||||
handler, err := StartHandler(dir, "", 0, secret, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
base := handler.ExternalURL()
|
|
||||||
|
|
||||||
post := func(path, bearer, body string) int {
|
|
||||||
req, err := http.NewRequest(http.MethodPost, base+path, strings.NewReader(body))
|
|
||||||
require.NoError(t, err)
|
|
||||||
if bearer != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
return resp.StatusCode
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("missing secret 401", func(t *testing.T) {
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, post("/_internal/register", "", `{"token":"x","repo":"r"}`))
|
|
||||||
})
|
|
||||||
t.Run("wrong secret 401", func(t *testing.T) {
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, post("/_internal/register", "wrong", `{"token":"x","repo":"r"}`))
|
|
||||||
})
|
|
||||||
t.Run("malformed body 400", func(t *testing.T) {
|
|
||||||
assert.Equal(t, http.StatusBadRequest, post("/_internal/register", secret, `not json`))
|
|
||||||
})
|
|
||||||
t.Run("missing token 400", func(t *testing.T) {
|
|
||||||
assert.Equal(t, http.StatusBadRequest, post("/_internal/register", secret, `{"repo":"r"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("register then revoke round-trip", func(t *testing.T) {
|
|
||||||
probe := func(token string) int {
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, base+apiPath+"/cache?keys=k&version=v", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
return resp.StatusCode
|
|
||||||
}
|
|
||||||
|
|
||||||
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.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.StatusUnauthorized, probe("via-internal-api"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ func (c *Request) ToCache() *Cache {
|
|||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
ID uint64 `json:"id" boltholdKey:"ID"`
|
ID uint64 `json:"id" boltholdKey:"ID"`
|
||||||
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"`
|
||||||
|
|||||||
30
act/artifactcache/testdata/example/example.yaml
vendored
Normal file
30
act/artifactcache/testdata/example/example.yaml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Copied from https://github.com/actions/cache#example-cache-workflow
|
||||||
|
name: Caching Primes
|
||||||
|
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- run: env
|
||||||
|
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Cache Primes
|
||||||
|
id: cache-primes
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: prime-numbers
|
||||||
|
key: ${{ runner.os }}-primes-${{ github.run_id }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-primes
|
||||||
|
${{ runner.os }}
|
||||||
|
|
||||||
|
- name: Generate Prime Numbers
|
||||||
|
if: steps.cache-primes.outputs.cache-hit != 'true'
|
||||||
|
run: cat /proc/sys/kernel/random/uuid > prime-numbers
|
||||||
|
|
||||||
|
- name: Use Prime Numbers
|
||||||
|
run: cat prime-numbers
|
||||||
@@ -5,25 +5,24 @@
|
|||||||
package artifacts
|
package artifacts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"compress/gzip"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"maps"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
"gitea.com/gitea/runner/act/runner"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type writableMapFile struct {
|
type writableMapFile struct {
|
||||||
@@ -203,7 +202,7 @@ func TestListArtifactContainer(t *testing.T) {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Len(response.Value, 1)
|
assert.Equal(1, len(response.Value)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal("some/file", response.Value[0].Path)
|
assert.Equal("some/file", response.Value[0].Path)
|
||||||
assert.Equal("file", response.Value[0].ItemType)
|
assert.Equal("file", response.Value[0].ItemType)
|
||||||
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
|
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
|
||||||
@@ -235,133 +234,89 @@ func TestDownloadArtifactFile(t *testing.T) {
|
|||||||
assert.Equal("content", string(data))
|
assert.Equal("content", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
|
type TestJobFileInfo struct {
|
||||||
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
|
workdir string
|
||||||
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
|
workflowPath string
|
||||||
// and reachable everywhere, including when the CI job is itself a container.
|
eventName string
|
||||||
|
errorMessage string
|
||||||
|
platforms map[string]string
|
||||||
|
containerArchitecture string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
artifactsPath = path.Join(os.TempDir(), "test-artifacts")
|
||||||
|
artifactsAddr = "127.0.0.1"
|
||||||
|
artifactsPort = "12345"
|
||||||
|
)
|
||||||
|
|
||||||
func TestArtifactFlow(t *testing.T) {
|
func TestArtifactFlow(t *testing.T) {
|
||||||
artifactPath := t.TempDir()
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
// Serve the exact routes Serve() wires up, on a real loopback socket via httptest. httptest
|
|
||||||
// picks a free port and Close() tears the server down synchronously — avoiding both the
|
|
||||||
// port-rebind race and Serve()'s detached ListenAndServe goroutine, which logger.Fatal()s
|
|
||||||
// (process exit) on a bind error and can outlive the test's temp-dir cleanup.
|
|
||||||
router := httprouter.New()
|
|
||||||
fsys := readWriteFSImpl{}
|
|
||||||
uploads(router, artifactPath, fsys)
|
|
||||||
downloads(router, artifactPath, fsys)
|
|
||||||
server := httptest.NewServer(router)
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
baseURL := server.URL
|
|
||||||
client := server.Client()
|
|
||||||
client.Timeout = 5 * time.Second
|
|
||||||
|
|
||||||
// request performs one HTTP call and returns the status and body. The default transport adds
|
|
||||||
// Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain.
|
|
||||||
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
|
|
||||||
t.Helper()
|
|
||||||
req, err := http.NewRequest(method, rawURL, body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
maps.Copy(req.Header, header)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return resp.StatusCode, data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("upload-and-download", func(t *testing.T) {
|
ctx := context.Background()
|
||||||
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n"
|
|
||||||
|
|
||||||
status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort)
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
defer cancel()
|
||||||
var prep FileContainerResourceURL
|
|
||||||
require.NoError(t, json.Unmarshal(data, &prep))
|
|
||||||
require.Equal(t, baseURL+"/upload/"+runID, prep.FileContainerResourceURL)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodPut, prep.FileContainerResourceURL+"?itemPath="+url.QueryEscape(item), strings.NewReader(content), nil)
|
platforms := map[string]string{
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
"ubuntu-latest": "node:16-buster", // Don't use node:16-buster-slim because it doesn't have curl command, which is used in the tests
|
||||||
var msg ResponseMessage
|
}
|
||||||
require.NoError(t, json.Unmarshal(data, &msg))
|
|
||||||
require.Equal(t, "success", msg.Message)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
tables := []TestJobFileInfo{
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
{"testdata", "upload-and-download", "push", "", platforms, ""},
|
||||||
|
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
|
||||||
|
}
|
||||||
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
for _, table := range tables {
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
runTestJobFile(ctx, t, table)
|
||||||
var list NamedFileContainerResourceURLResponse
|
}
|
||||||
require.NoError(t, json.Unmarshal(data, &list))
|
}
|
||||||
require.Equal(t, 1, list.Count)
|
|
||||||
require.Equal(t, "my-artifact", list.Value[0].Name)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, list.Value[0].FileContainerResourceURL+"?itemPath=my-artifact", nil, nil)
|
func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
t.Run(tjfi.workflowPath, func(t *testing.T) {
|
||||||
var items ContainerItemResponse
|
fmt.Printf("::group::%s\n", tjfi.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
require.NoError(t, json.Unmarshal(data, &items))
|
|
||||||
require.Len(t, items.Value, 1)
|
|
||||||
require.Equal(t, "file", items.Value[0].ItemType)
|
|
||||||
require.Equal(t, "my-artifact/data.txt", items.Value[0].Path)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
if err := os.RemoveAll(artifactsPath); err != nil {
|
||||||
require.Equal(t, http.StatusOK, status)
|
panic(err)
|
||||||
require.Equal(t, content, string(data))
|
}
|
||||||
|
|
||||||
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt"))
|
workdir, err := filepath.Abs(tjfi.workdir)
|
||||||
require.NoError(t, err)
|
assert.Nil(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
require.Equal(t, content, string(stored))
|
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
|
||||||
})
|
runnerConfig := &runner.Config{
|
||||||
|
Workdir: workdir,
|
||||||
|
BindWorkdir: false,
|
||||||
|
EventName: tjfi.eventName,
|
||||||
|
Platforms: tjfi.platforms,
|
||||||
|
ReuseContainers: false,
|
||||||
|
ContainerArchitecture: tjfi.containerArchitecture,
|
||||||
|
GitHubInstance: "github.com",
|
||||||
|
ArtifactServerPath: artifactsPath,
|
||||||
|
ArtifactServerAddr: artifactsAddr,
|
||||||
|
ArtifactServerPort: artifactsPort,
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("gzip-roundtrip", func(t *testing.T) {
|
runner, err := runner.New(runnerConfig)
|
||||||
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
assert.Nil(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
var buf bytes.Buffer
|
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
||||||
gz := gzip.NewWriter(&buf)
|
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
_, err := gz.Write([]byte(content))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, gz.Close())
|
|
||||||
|
|
||||||
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape(item),
|
plan, err := planner.PlanEvent(tjfi.eventName)
|
||||||
&buf, http.Header{"Content-Encoding": []string{"gzip"}})
|
if err == nil {
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
err = runner.NewPlanExecutor(plan)(ctx)
|
||||||
|
if tjfi.errorMessage == "" {
|
||||||
|
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
} else {
|
||||||
|
assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, plan)
|
||||||
|
}
|
||||||
|
|
||||||
// stored compressed, with the server's gzip marker suffix
|
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
_, err = os.Stat(filepath.Join(artifactPath, runID, "logs", "app.log.gz__"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, baseURL+"/download/"+runID+"?itemPath=logs", nil, nil)
|
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
|
||||||
var items ContainerItemResponse
|
|
||||||
require.NoError(t, json.Unmarshal(data, &items))
|
|
||||||
require.Len(t, items.Value, 1)
|
|
||||||
require.Equal(t, "logs/app.log", items.Value[0].Path)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
|
||||||
require.Equal(t, http.StatusOK, status)
|
|
||||||
require.Equal(t, content, string(data))
|
|
||||||
})
|
|
||||||
|
|
||||||
// GHSL-2023-004: an itemPath that climbs out of the run directory must be neutralised so the
|
|
||||||
// blob cannot be written outside the artifact root.
|
|
||||||
t.Run("GHSL-2023-004", func(t *testing.T) {
|
|
||||||
const runID, content = "3", "contained\n"
|
|
||||||
|
|
||||||
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape("../../escape.txt"),
|
|
||||||
strings.NewReader(content), nil)
|
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
|
||||||
|
|
||||||
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "escape.txt"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, content, string(stored))
|
|
||||||
|
|
||||||
_, err = os.Stat(filepath.Join(filepath.Dir(artifactPath), "escape.txt"))
|
|
||||||
require.True(t, os.IsNotExist(err), "upload escaped the artifact root")
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, baseURL+"/artifact/"+runID+"/escape.txt", nil, nil)
|
|
||||||
require.Equal(t, http.StatusOK, status)
|
|
||||||
require.Equal(t, content, string(data))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
act/artifacts/testdata/GHSL-2023-004/artifacts.yml
vendored
Normal file
39
act/artifacts/testdata/GHSL-2023-004/artifacts.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
name: "GHSL-2023-0004"
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-artifacts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "hello world" > test.txt
|
||||||
|
- name: curl upload
|
||||||
|
run: curl --silent --show-error --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt
|
||||||
|
- uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
path: test-artifacts
|
||||||
|
- name: 'Verify Artifact #1'
|
||||||
|
run: |
|
||||||
|
file="test-artifacts/secret.txt"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file)" != "hello world" ] ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- name: Verify download should work by clean extra dots
|
||||||
|
run: curl --silent --show-error --fail --path-as-is -o out.txt ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt
|
||||||
|
- name: 'Verify download content'
|
||||||
|
run: |
|
||||||
|
file="out.txt"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file)" != "hello world" ] ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
230
act/artifacts/testdata/upload-and-download/artifacts.yml
vendored
Normal file
230
act/artifacts/testdata/upload-and-download/artifacts.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
|
||||||
|
name: "Test that artifact uploads and downloads succeed"
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-artifacts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: mkdir -p path/to/artifact
|
||||||
|
- run: echo hello > path/to/artifact/world.txt
|
||||||
|
- uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
path: path/to/artifact/world.txt
|
||||||
|
|
||||||
|
- run: rm -rf path
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
- name: Display structure of downloaded files
|
||||||
|
run: ls -la
|
||||||
|
|
||||||
|
# Test end-to-end by uploading two artifacts and then downloading them
|
||||||
|
- name: Create artifact files
|
||||||
|
run: |
|
||||||
|
mkdir -p path/to/dir-1
|
||||||
|
mkdir -p path/to/dir-2
|
||||||
|
mkdir -p path/to/dir-3
|
||||||
|
mkdir -p path/to/dir-5
|
||||||
|
mkdir -p path/to/dir-6
|
||||||
|
mkdir -p path/to/dir-7
|
||||||
|
echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt
|
||||||
|
echo "Hello world from file #2" > path/to/dir-2/file2.txt
|
||||||
|
echo "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" > path/to/dir-3/gzip.txt
|
||||||
|
dd if=/dev/random of=path/to/dir-5/file5.rnd bs=1024 count=1024
|
||||||
|
dd if=/dev/random of=path/to/dir-6/file6.rnd bs=1024 count=$((10*1024))
|
||||||
|
dd if=/dev/random of=path/to/dir-7/file7.rnd bs=1024 count=$((10*1024))
|
||||||
|
|
||||||
|
# Upload a single file artifact
|
||||||
|
- name: 'Upload artifact #1'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Artifact-A'
|
||||||
|
path: path/to/dir-1/file1.txt
|
||||||
|
|
||||||
|
# Upload using a wildcard pattern, name should default to 'artifact' if not provided
|
||||||
|
- name: 'Upload artifact #2'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
path: path/**/dir*/
|
||||||
|
|
||||||
|
# Upload a directory that contains a file that will be uploaded with GZip
|
||||||
|
- name: 'Upload artifact #3'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'GZip-Artifact'
|
||||||
|
path: path/to/dir-3/
|
||||||
|
|
||||||
|
# Upload a directory that contains a file that will be uploaded with GZip
|
||||||
|
- name: 'Upload artifact #4'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Multi-Path-Artifact'
|
||||||
|
path: |
|
||||||
|
path/to/dir-1/*
|
||||||
|
path/to/dir-[23]/*
|
||||||
|
!path/to/dir-3/*.txt
|
||||||
|
|
||||||
|
# Upload a mid-size file artifact
|
||||||
|
- name: 'Upload artifact #5'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Mid-Size-Artifact'
|
||||||
|
path: path/to/dir-5/file5.rnd
|
||||||
|
|
||||||
|
# Upload a big file artifact
|
||||||
|
- name: 'Upload artifact #6'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Artifact'
|
||||||
|
path: path/to/dir-6/file6.rnd
|
||||||
|
|
||||||
|
# Upload a big file artifact twice
|
||||||
|
- name: 'Upload artifact #7 (First)'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Uploaded-Twice'
|
||||||
|
path: path/to/dir-7/file7.rnd
|
||||||
|
|
||||||
|
# Upload a big file artifact twice
|
||||||
|
- name: 'Upload artifact #7 (Second)'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Uploaded-Twice'
|
||||||
|
path: path/to/dir-7/file7.rnd
|
||||||
|
|
||||||
|
# Verify artifacts. Switch to download-artifact@v2 once it's out of preview
|
||||||
|
|
||||||
|
# Download Artifact #1 and verify the correctness of the content
|
||||||
|
- name: 'Download artifact #1'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Artifact-A'
|
||||||
|
path: some/new/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #1'
|
||||||
|
run: |
|
||||||
|
file="some/new/path/file1.txt"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file)" != "Lorem ipsum dolor sit amet" ] ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download Artifact #2 and verify the correctness of the content
|
||||||
|
- name: 'Download artifact #2'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'artifact'
|
||||||
|
path: some/other/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #2'
|
||||||
|
run: |
|
||||||
|
file1="some/other/path/to/dir-1/file1.txt"
|
||||||
|
file2="some/other/path/to/dir-2/file2.txt"
|
||||||
|
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||||
|
echo "Expected files do not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||||
|
echo "File contents of downloaded artifacts are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download Artifact #3 and verify the correctness of the content
|
||||||
|
- name: 'Download artifact #3'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'GZip-Artifact'
|
||||||
|
path: gzip/artifact/path
|
||||||
|
|
||||||
|
# Because a directory was used as input during the upload the parent directories, path/to/dir-3/, should not be included in the uploaded artifact
|
||||||
|
- name: 'Verify Artifact #3'
|
||||||
|
run: |
|
||||||
|
gzipFile="gzip/artifact/path/gzip.txt"
|
||||||
|
if [ ! -f $gzipFile ] ; then
|
||||||
|
echo "Expected file do not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $gzipFile)" != "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" ] ; then
|
||||||
|
echo "File contents of downloaded artifact is incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #4'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Multi-Path-Artifact'
|
||||||
|
path: multi/artifact
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #4'
|
||||||
|
run: |
|
||||||
|
file1="multi/artifact/dir-1/file1.txt"
|
||||||
|
file2="multi/artifact/dir-2/file2.txt"
|
||||||
|
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||||
|
echo "Expected files do not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||||
|
echo "File contents of downloaded artifacts are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #5'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Mid-Size-Artifact'
|
||||||
|
path: mid-size/artifact/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #5'
|
||||||
|
run: |
|
||||||
|
file="mid-size/artifact/path/file5.rnd"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! diff $file path/to/dir-5/file5.rnd ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #6'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Artifact'
|
||||||
|
path: big/artifact/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #6'
|
||||||
|
run: |
|
||||||
|
file="big/artifact/path/file6.rnd"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! diff $file path/to/dir-6/file6.rnd ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #7'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Uploaded-Twice'
|
||||||
|
path: big-uploaded-twice/artifact/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #7'
|
||||||
|
run: |
|
||||||
|
file="big-uploaded-twice/artifact/path/file7.rnd"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! diff $file path/to/dir-7/file7.rnd ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -4,8 +4,6 @@
|
|||||||
|
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import "slices"
|
|
||||||
|
|
||||||
// CartesianProduct takes map of lists and returns list of unique tuples
|
// CartesianProduct takes map of lists and returns list of unique tuples
|
||||||
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
||||||
listNames := make([]string, 0)
|
listNames := make([]string, 0)
|
||||||
@@ -48,7 +46,7 @@ func cartN(a ...[]any) [][]any {
|
|||||||
for j, n := range n {
|
for j, n := range n {
|
||||||
pi[j] = a[j][n]
|
pi[j] = a[j][n]
|
||||||
}
|
}
|
||||||
for j := range slices.Backward(n) {
|
for j := len(n) - 1; j >= 0; j-- {
|
||||||
n[j]++
|
n[j]++
|
||||||
if n[j] < len(a[j]) {
|
if n[j] < len(a[j]) {
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ func TestCartesianProduct(t *testing.T) {
|
|||||||
"baz": {false, true},
|
"baz": {false, true},
|
||||||
}
|
}
|
||||||
output = CartesianProduct(input)
|
output = CartesianProduct(input)
|
||||||
assert.Empty(output)
|
assert.Len(output, 0) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
input = map[string][]any{}
|
input = map[string][]any{}
|
||||||
output = CartesianProduct(input)
|
output = CartesianProduct(input)
|
||||||
assert.Empty(output)
|
assert.Len(output, 0) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|||||||
146
act/common/draw.go
Normal file
146
act/common/draw.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Style is a specific style
|
||||||
|
type Style int
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const (
|
||||||
|
StyleDoubleLine = iota
|
||||||
|
StyleSingleLine
|
||||||
|
StyleDashedLine
|
||||||
|
StyleNoLine
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPen creates a new pen
|
||||||
|
func NewPen(style Style, color int) *Pen {
|
||||||
|
bgcolor := 49
|
||||||
|
if os.Getenv("CLICOLOR") == "0" {
|
||||||
|
color = 0
|
||||||
|
bgcolor = 0
|
||||||
|
}
|
||||||
|
return &Pen{
|
||||||
|
style: style,
|
||||||
|
color: color,
|
||||||
|
bgcolor: bgcolor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type styleDef struct {
|
||||||
|
cornerTL string
|
||||||
|
cornerTR string
|
||||||
|
cornerBL string
|
||||||
|
cornerBR string
|
||||||
|
lineH string
|
||||||
|
lineV string
|
||||||
|
}
|
||||||
|
|
||||||
|
var styleDefs = []styleDef{
|
||||||
|
{"\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"},
|
||||||
|
{"\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"},
|
||||||
|
{"\u250c", "\u2510", "\u2514", "\u2518", "\u254c", "\u254e"},
|
||||||
|
{" ", " ", " ", " ", " ", " "},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pen struct
|
||||||
|
type Pen struct {
|
||||||
|
style Style
|
||||||
|
color int
|
||||||
|
bgcolor int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawing struct
|
||||||
|
type Drawing struct {
|
||||||
|
buf *strings.Builder
|
||||||
|
width int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pen) drawTopBars(buf io.Writer, labels ...string) {
|
||||||
|
style := styleDefs[p.style]
|
||||||
|
for _, label := range labels {
|
||||||
|
bar := strings.Repeat(style.lineH, len(label)+2)
|
||||||
|
fmt.Fprintf(buf, " ")
|
||||||
|
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||||
|
fmt.Fprintf(buf, "%s%s%s", style.cornerTL, bar, style.cornerTR)
|
||||||
|
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(buf, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pen) drawBottomBars(buf io.Writer, labels ...string) {
|
||||||
|
style := styleDefs[p.style]
|
||||||
|
for _, label := range labels {
|
||||||
|
bar := strings.Repeat(style.lineH, len(label)+2)
|
||||||
|
fmt.Fprintf(buf, " ")
|
||||||
|
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||||
|
fmt.Fprintf(buf, "%s%s%s", style.cornerBL, bar, style.cornerBR)
|
||||||
|
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(buf, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pen) drawLabels(buf io.Writer, labels ...string) {
|
||||||
|
style := styleDefs[p.style]
|
||||||
|
for _, label := range labels {
|
||||||
|
fmt.Fprintf(buf, " ")
|
||||||
|
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
||||||
|
fmt.Fprintf(buf, "%s %s %s", style.lineV, label, style.lineV)
|
||||||
|
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(buf, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawArrow between boxes
|
||||||
|
func (p *Pen) DrawArrow() *Drawing {
|
||||||
|
drawing := &Drawing{
|
||||||
|
buf: new(strings.Builder),
|
||||||
|
width: 1,
|
||||||
|
}
|
||||||
|
fmt.Fprintf(drawing.buf, "\x1b[%dm", p.color)
|
||||||
|
fmt.Fprintf(drawing.buf, "\u2b07")
|
||||||
|
fmt.Fprintf(drawing.buf, "\x1b[%dm", 0)
|
||||||
|
return drawing
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawBoxes to draw boxes
|
||||||
|
func (p *Pen) DrawBoxes(labels ...string) *Drawing {
|
||||||
|
width := 0
|
||||||
|
for _, l := range labels {
|
||||||
|
width += len(l) + 2 + 2 + 1
|
||||||
|
}
|
||||||
|
drawing := &Drawing{
|
||||||
|
buf: new(strings.Builder),
|
||||||
|
width: width,
|
||||||
|
}
|
||||||
|
p.drawTopBars(drawing.buf, labels...)
|
||||||
|
p.drawLabels(drawing.buf, labels...)
|
||||||
|
p.drawBottomBars(drawing.buf, labels...)
|
||||||
|
|
||||||
|
return drawing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw to writer
|
||||||
|
func (d *Drawing) Draw(writer io.Writer, centerOnWidth int) {
|
||||||
|
padSize := max((centerOnWidth-d.GetWidth())/2, 0)
|
||||||
|
for l := range strings.SplitSeq(d.buf.String(), "\n") {
|
||||||
|
if len(l) > 0 {
|
||||||
|
padding := strings.Repeat(" ", padSize)
|
||||||
|
fmt.Fprintf(writer, "%s%s\n", padding, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWidth of drawing
|
||||||
|
func (d *Drawing) GetWidth() int {
|
||||||
|
return d.width
|
||||||
|
}
|
||||||
@@ -12,6 +12,24 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Warning that implements `error` but safe to ignore
|
||||||
|
type Warning struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error the contract for error
|
||||||
|
func (w Warning) Error() string {
|
||||||
|
return w.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warningf create a warning
|
||||||
|
func Warningf(format string, args ...any) Warning {
|
||||||
|
w := Warning{
|
||||||
|
Message: fmt.Sprintf(format, args...),
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
// Executor define contract for the steps of a workflow
|
// Executor define contract for the steps of a workflow
|
||||||
type Executor func(ctx context.Context) error
|
type Executor func(ctx context.Context) error
|
||||||
|
|
||||||
@@ -79,12 +97,6 @@ func NewErrorExecutor(err error) Executor {
|
|||||||
|
|
||||||
// NewParallelExecutor creates a new executor from a parallel of other executors
|
// NewParallelExecutor creates a new executor from a parallel of other executors
|
||||||
func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
||||||
if len(executors) == 0 {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
work := make(chan Executor, len(executors))
|
work := make(chan Executor, len(executors))
|
||||||
errs := make(chan error, len(executors))
|
errs := make(chan error, len(executors))
|
||||||
@@ -144,8 +156,14 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
|||||||
// Then runs another executor if this executor succeeds
|
// Then runs another executor if this executor succeeds
|
||||||
func (e Executor) Then(then Executor) Executor {
|
func (e Executor) Then(then Executor) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if err := e(ctx); err != nil {
|
err := e(ctx)
|
||||||
return err
|
if err != nil {
|
||||||
|
switch err.(type) {
|
||||||
|
case Warning:
|
||||||
|
Logger(ctx).Warning(err.Error())
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
|||||||
@@ -170,6 +170,68 @@ func TestMaxParallelWithErrors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMaxParallelPerformance tests performance characteristics
|
||||||
|
func TestMaxParallelPerformance(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping performance test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("ParallelFasterThanSequential", func(t *testing.T) {
|
||||||
|
executors := make([]Executor, 10)
|
||||||
|
for i := range 10 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Sequential (max-parallel=1)
|
||||||
|
start := time.Now()
|
||||||
|
err := NewParallelExecutor(1, executors...)(ctx)
|
||||||
|
sequentialDuration := time.Since(start)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Parallel (max-parallel=5)
|
||||||
|
start = time.Now()
|
||||||
|
err = NewParallelExecutor(5, executors...)(ctx)
|
||||||
|
parallelDuration := time.Since(start)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Parallel should be significantly faster
|
||||||
|
assert.Less(t, parallelDuration, sequentialDuration/2,
|
||||||
|
"Parallel execution should be at least 2x faster")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OptimalWorkerCount", func(t *testing.T) {
|
||||||
|
executors := make([]Executor, 20)
|
||||||
|
for i := range 20 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test with different worker counts
|
||||||
|
workerCounts := []int{1, 2, 5, 10, 20}
|
||||||
|
durations := make(map[int]time.Duration)
|
||||||
|
|
||||||
|
for _, count := range workerCounts {
|
||||||
|
start := time.Now()
|
||||||
|
err := NewParallelExecutor(count, executors...)(ctx)
|
||||||
|
durations[count] = time.Since(start)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
// More workers should generally be faster (up to a point)
|
||||||
|
assert.Less(t, durations[5], durations[1], "5 workers should be faster than 1")
|
||||||
|
assert.Less(t, durations[10], durations[2], "10 workers should be faster than 2")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
||||||
func TestMaxParallelResourceSharing(t *testing.T) {
|
func TestMaxParallelResourceSharing(t *testing.T) {
|
||||||
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewWorkflow(t *testing.T) {
|
func TestNewWorkflow(t *testing.T) {
|
||||||
@@ -22,11 +21,11 @@ func TestNewWorkflow(t *testing.T) {
|
|||||||
|
|
||||||
// empty
|
// empty
|
||||||
emptyWorkflow := NewPipelineExecutor()
|
emptyWorkflow := NewPipelineExecutor()
|
||||||
assert.NoError(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// error case
|
// error case
|
||||||
errorWorkflow := NewErrorExecutor(errors.New("test error"))
|
errorWorkflow := NewErrorExecutor(errors.New("test error"))
|
||||||
assert.Error(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NotNil(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// multiple success case
|
// multiple success case
|
||||||
runcount := 0
|
runcount := 0
|
||||||
@@ -39,7 +38,7 @@ func TestNewWorkflow(t *testing.T) {
|
|||||||
runcount++
|
runcount++
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
assert.NoError(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(2, runcount)
|
assert.Equal(2, runcount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ func TestNewConditionalExecutor(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})(ctx)
|
})(ctx)
|
||||||
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(0, trueCount)
|
assert.Equal(0, trueCount)
|
||||||
assert.Equal(1, falseCount)
|
assert.Equal(1, falseCount)
|
||||||
|
|
||||||
@@ -75,7 +74,7 @@ func TestNewConditionalExecutor(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})(ctx)
|
})(ctx)
|
||||||
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(1, trueCount)
|
assert.Equal(1, trueCount)
|
||||||
assert.Equal(1, falseCount)
|
assert.Equal(1, falseCount)
|
||||||
}
|
}
|
||||||
@@ -106,7 +105,7 @@ func TestNewParallelExecutor(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||||
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// Reset to test running the executor with 0 parallelism
|
// Reset to test running the executor with 0 parallelism
|
||||||
count.Store(0)
|
count.Store(0)
|
||||||
@@ -117,20 +116,7 @@ func TestNewParallelExecutor(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
||||||
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
|
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
|
||||||
assert.NoError(errSingle)
|
assert.Nil(errSingle) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, NewParallelExecutor(2)(ctx))
|
|
||||||
|
|
||||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
err := NewParallelExecutor(2)(canceledCtx)
|
|
||||||
assert.ErrorIs(err, context.Canceled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewParallelExecutorFailed(t *testing.T) {
|
func TestNewParallelExecutorFailed(t *testing.T) {
|
||||||
|
|||||||
77
act/common/file.go
Normal file
77
act/common/file.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CopyFile copy file
|
||||||
|
func CopyFile(source, dest string) (err error) {
|
||||||
|
sourcefile, err := os.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer sourcefile.Close()
|
||||||
|
|
||||||
|
destfile, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer destfile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(destfile, sourcefile)
|
||||||
|
if err == nil {
|
||||||
|
sourceinfo, err := os.Stat(source)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Chmod(dest, sourceinfo.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyDir recursive copy of directory
|
||||||
|
func CopyDir(source, dest string) (err error) {
|
||||||
|
// get properties of source dir
|
||||||
|
sourceinfo, err := os.Stat(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// create dest dir
|
||||||
|
|
||||||
|
err = os.MkdirAll(dest, sourceinfo.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
objects, err := os.ReadDir(source)
|
||||||
|
|
||||||
|
for _, obj := range objects {
|
||||||
|
sourcefilepointer := source + "/" + obj.Name()
|
||||||
|
|
||||||
|
destinationfilepointer := dest + "/" + obj.Name()
|
||||||
|
|
||||||
|
if obj.IsDir() {
|
||||||
|
// create sub-directories - recursively
|
||||||
|
err = CopyDir(sourcefilepointer, destinationfilepointer)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// perform copy
|
||||||
|
err = CopyFile(sourcefilepointer, destinationfilepointer)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -32,23 +32,12 @@ var (
|
|||||||
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||||
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||||
|
|
||||||
cloneLocks sync.Map // key: clone target directory; value: *sync.Mutex
|
cloneLock sync.Mutex
|
||||||
|
|
||||||
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")
|
ErrNoRepo = errors.New("unable to find git repo")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
|
||||||
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
|
|
||||||
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
|
||||||
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
|
||||||
func AcquireCloneLock(dir string) func() {
|
|
||||||
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
|
||||||
mu := v.(*sync.Mutex)
|
|
||||||
mu.Lock()
|
|
||||||
return mu.Unlock
|
|
||||||
}
|
|
||||||
|
|
||||||
type Error struct {
|
type Error struct {
|
||||||
err error
|
err error
|
||||||
commit string
|
commit string
|
||||||
@@ -66,21 +55,8 @@ func (e *Error) Commit() string {
|
|||||||
return e.commit
|
return e.commit
|
||||||
}
|
}
|
||||||
|
|
||||||
// goGitMu serializes go-git repository access across the process. go-git is not safe for
|
|
||||||
// concurrent use of the same repository (even read access decodes packfiles into shared
|
|
||||||
// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded
|
|
||||||
// operations are fast local reads; gitea runs one job per process, so the lock is effectively
|
|
||||||
// uncontended in production.
|
|
||||||
var goGitMu sync.Mutex
|
|
||||||
|
|
||||||
// FindGitRevision get the current git revision
|
// FindGitRevision get the current git revision
|
||||||
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||||
goGitMu.Lock()
|
|
||||||
defer goGitMu.Unlock()
|
|
||||||
return findGitRevision(ctx, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
gitDir, err := git.PlainOpenWithOptions(
|
gitDir, err := git.PlainOpenWithOptions(
|
||||||
@@ -112,13 +88,10 @@ func findGitRevision(ctx context.Context, file string) (shortSha, sha string, er
|
|||||||
|
|
||||||
// FindGitRef get the current git ref
|
// FindGitRef get the current git ref
|
||||||
func FindGitRef(ctx context.Context, file string) (string, error) {
|
func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||||
goGitMu.Lock()
|
|
||||||
defer goGitMu.Unlock()
|
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
logger.Debugf("Loading revision from git directory")
|
logger.Debugf("Loading revision from git directory")
|
||||||
_, ref, err := findGitRevision(ctx, file)
|
_, ref, err := FindGitRevision(ctx, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -190,8 +163,6 @@ 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, remoteName string) (string, error) {
|
||||||
goGitMu.Lock()
|
|
||||||
defer goGitMu.Unlock()
|
|
||||||
if remoteName == "" {
|
if remoteName == "" {
|
||||||
remoteName = "origin"
|
remoteName = "origin"
|
||||||
}
|
}
|
||||||
@@ -261,50 +232,47 @@ type NewGitCloneExecutorInput struct {
|
|||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused.
|
// CloneIfRequired ...
|
||||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) {
|
||||||
r, err := git.PlainOpen(input.Dir)
|
r, err := git.PlainOpen(input.Dir)
|
||||||
if err == nil {
|
|
||||||
// Reuse existing clone
|
|
||||||
return r, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var progressWriter io.Writer
|
|
||||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
|
||||||
if entry, ok := logger.(*log.Entry); ok {
|
|
||||||
progressWriter = entry.WriterLevel(log.DebugLevel)
|
|
||||||
} else if lgr, ok := logger.(*log.Logger); ok {
|
|
||||||
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
|
||||||
} else {
|
|
||||||
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
|
||||||
progressWriter = os.Stdout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cloneOptions := git.CloneOptions{
|
|
||||||
URL: input.URL,
|
|
||||||
Progress: progressWriter,
|
|
||||||
|
|
||||||
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
|
|
||||||
}
|
|
||||||
if input.Token != "" {
|
|
||||||
cloneOptions.Auth = &http.BasicAuth{
|
|
||||||
Username: "token",
|
|
||||||
Password: input.Token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
var progressWriter io.Writer
|
||||||
return nil, false, err
|
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||||
|
if entry, ok := logger.(*log.Entry); ok {
|
||||||
|
progressWriter = entry.WriterLevel(log.DebugLevel)
|
||||||
|
} else if lgr, ok := logger.(*log.Logger); ok {
|
||||||
|
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
||||||
|
} else {
|
||||||
|
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
||||||
|
progressWriter = os.Stdout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneOptions := git.CloneOptions{
|
||||||
|
URL: input.URL,
|
||||||
|
Progress: progressWriter,
|
||||||
|
|
||||||
|
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
|
||||||
|
}
|
||||||
|
if input.Token != "" {
|
||||||
|
cloneOptions.Auth = &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: input.Token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
return r, nil
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return r, false, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
||||||
@@ -325,16 +293,19 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewGitCloneExecutor creates an executor to clone git repos
|
// NewGitCloneExecutor creates an executor to clone git repos
|
||||||
|
//
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||||
|
|
||||||
defer AcquireCloneLock(input.Dir)()
|
cloneLock.Lock()
|
||||||
|
defer cloneLock.Unlock()
|
||||||
|
|
||||||
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
||||||
r, reused, err := CloneIfRequired(ctx, refName, input, logger)
|
r, err := CloneIfRequired(ctx, refName, input, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -359,10 +330,10 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
var hash *plumbing.Hash
|
var hash *plumbing.Hash
|
||||||
rev := plumbing.Revision(input.Ref)
|
rev := plumbing.Revision(input.Ref)
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
// ResolveRevision returns a nil hash on error, and a branch ref legitimately fails
|
|
||||||
// here (no local refs/heads/<ref>); the duck-typing below resolves it.
|
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
} else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
|
}
|
||||||
|
|
||||||
|
if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
|
||||||
return &Error{
|
return &Error{
|
||||||
err: ErrShortRef,
|
err: ErrShortRef,
|
||||||
commit: hash.String(),
|
commit: hash.String(),
|
||||||
@@ -413,18 +384,12 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reusedMsg := ""
|
|
||||||
|
|
||||||
if !isOfflineMode {
|
if !isOfflineMode {
|
||||||
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||||
}
|
}
|
||||||
} else if reused {
|
|
||||||
reusedMsg = " (reused in offline mode)"
|
|
||||||
}
|
}
|
||||||
|
logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
|
||||||
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
|
||||||
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
if hash.String() != input.Ref && refType == "branch" {
|
||||||
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -49,6 +48,10 @@ func TestFindGitSlug(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDir(t *testing.T) string {
|
||||||
|
return t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
func cleanGitHooks(dir string) error {
|
func cleanGitHooks(dir string) error {
|
||||||
hooksDir := filepath.Join(dir, ".git", "hooks")
|
hooksDir := filepath.Join(dir, ".git", "hooks")
|
||||||
files, err := os.ReadDir(hooksDir)
|
files, err := os.ReadDir(hooksDir)
|
||||||
@@ -73,7 +76,8 @@ func cleanGitHooks(dir string) error {
|
|||||||
func TestFindGitRemoteURL(t *testing.T) {
|
func TestFindGitRemoteURL(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
basedir := t.TempDir()
|
basedir := testDir(t)
|
||||||
|
gitConfig()
|
||||||
err := gitCmd("init", basedir)
|
err := gitCmd("init", basedir)
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
err = cleanGitHooks(basedir)
|
err = cleanGitHooks(basedir)
|
||||||
@@ -96,7 +100,8 @@ func TestFindGitRemoteURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGitFindRef(t *testing.T) {
|
func TestGitFindRef(t *testing.T) {
|
||||||
basedir := t.TempDir()
|
basedir := testDir(t)
|
||||||
|
gitConfig()
|
||||||
|
|
||||||
for name, tt := range map[string]struct {
|
for name, tt := range map[string]struct {
|
||||||
Prepare func(t *testing.T, dir string)
|
Prepare func(t *testing.T, dir string)
|
||||||
@@ -173,55 +178,36 @@ func TestGitFindRef(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutor(t *testing.T) {
|
func TestGitCloneExecutor(t *testing.T) {
|
||||||
// Build a local bare "remote" so this runs offline and fast. The cases below mirror
|
|
||||||
// the tag/branch/sha/short-sha ref paths the executor handles, formerly exercised by
|
|
||||||
// cloning actions/checkout and anchore/scan-action over the network.
|
|
||||||
remoteDir := t.TempDir()
|
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
|
||||||
|
|
||||||
workDir := t.TempDir()
|
|
||||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
|
||||||
|
|
||||||
// A branch with a dash in the name (mirrors the historical scan-action@act-fails case).
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
|
|
||||||
|
|
||||||
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
|
|
||||||
require.NoError(t, err)
|
|
||||||
fullSha := strings.TrimSpace(string(out))
|
|
||||||
|
|
||||||
for name, tt := range map[string]struct {
|
for name, tt := range map[string]struct {
|
||||||
Err error
|
Err error
|
||||||
Ref string
|
URL, Ref string
|
||||||
}{
|
}{
|
||||||
"tag": {
|
"tag": {
|
||||||
Err: nil,
|
Err: nil,
|
||||||
|
URL: "https://github.com/actions/checkout",
|
||||||
Ref: "v2",
|
Ref: "v2",
|
||||||
},
|
},
|
||||||
"branch": {
|
"branch": {
|
||||||
Err: nil,
|
Err: nil,
|
||||||
|
URL: "https://github.com/anchore/scan-action",
|
||||||
Ref: "act-fails",
|
Ref: "act-fails",
|
||||||
},
|
},
|
||||||
"sha": {
|
"sha": {
|
||||||
Err: nil,
|
Err: nil,
|
||||||
Ref: fullSha,
|
URL: "https://github.com/actions/checkout",
|
||||||
|
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
|
||||||
},
|
},
|
||||||
"short-sha": {
|
"short-sha": {
|
||||||
Err: &Error{ErrShortRef, fullSha},
|
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"},
|
||||||
Ref: fullSha[:7],
|
URL: "https://github.com/actions/checkout",
|
||||||
|
Ref: "5a4ac90", // v2
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir,
|
URL: tt.URL,
|
||||||
Ref: tt.Ref,
|
Ref: tt.Ref,
|
||||||
Dir: t.TempDir(),
|
Dir: testDir(t),
|
||||||
})
|
})
|
||||||
|
|
||||||
err := clone(context.Background())
|
err := clone(context.Background())
|
||||||
@@ -240,6 +226,8 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
|||||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
||||||
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
|
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
|
||||||
|
|
||||||
|
gitConfig()
|
||||||
|
|
||||||
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
@@ -289,67 +277,22 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
|||||||
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
|
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
func gitConfig() {
|
||||||
// Build a local "remote" with a single commit on main.
|
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||||
remoteDir := t.TempDir()
|
var err error
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
if err = gitCmd("config", "--global", "user.email", "test@test.com"); err != nil {
|
||||||
workDir := t.TempDir()
|
log.Error(err)
|
||||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
}
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil {
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
log.Error(err)
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
}
|
||||||
|
}
|
||||||
// Prime the cache with an online clone of main.
|
|
||||||
cacheDir := t.TempDir()
|
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
||||||
URL: remoteDir,
|
|
||||||
Ref: "main",
|
|
||||||
Dir: cacheDir,
|
|
||||||
})(context.Background()))
|
|
||||||
|
|
||||||
t.Run("cached branch resolves without fetching", func(t *testing.T) {
|
|
||||||
// Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref)
|
|
||||||
// finds no local refs/heads/<ref>.
|
|
||||||
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
||||||
URL: remoteDir,
|
|
||||||
Ref: "main",
|
|
||||||
Dir: cacheDir,
|
|
||||||
OfflineMode: true,
|
|
||||||
})(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "initial", strings.TrimSpace(string(out)))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("unresolvable cached ref returns error", func(t *testing.T) {
|
|
||||||
// The ref was never cached; offline mode cannot resolve it and must return an error.
|
|
||||||
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
||||||
URL: remoteDir,
|
|
||||||
Ref: "never-fetched",
|
|
||||||
Dir: cacheDir,
|
|
||||||
OfflineMode: true,
|
|
||||||
})(context.Background())
|
|
||||||
require.Error(t, err)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitCmd(args ...string) error {
|
func gitCmd(args ...string) error {
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", args...)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
// Inject a deterministic identity and ignore the host's global/system config so commits
|
|
||||||
// succeed regardless of the host having no user.name/user.email (e.g. CI, GITHUB_ACTIONS
|
|
||||||
// unset) or a global commit.gpgsign, and without mutating the developer's ~/.gitconfig.
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GIT_AUTHOR_NAME=Unit Test",
|
|
||||||
"GIT_AUTHOR_EMAIL=test@test.com",
|
|
||||||
"GIT_COMMITTER_NAME=Unit Test",
|
|
||||||
"GIT_COMMITTER_EMAIL=test@test.com",
|
|
||||||
"GIT_CONFIG_GLOBAL=/dev/null",
|
|
||||||
"GIT_CONFIG_SYSTEM=/dev/null",
|
|
||||||
)
|
|
||||||
|
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if exitError, ok := err.(*exec.ExitError); ok {
|
if exitError, ok := err.(*exec.ExitError); ok {
|
||||||
@@ -360,61 +303,3 @@ func gitCmd(args ...string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcquireCloneLock(t *testing.T) {
|
|
||||||
t.Run("same directory serializes", func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
unlock1 := AcquireCloneLock(dir)
|
|
||||||
|
|
||||||
secondAcquired := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
unlock := AcquireCloneLock(dir)
|
|
||||||
close(secondAcquired)
|
|
||||||
unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-secondAcquired:
|
|
||||||
t.Fatal("second acquire should block while first holds the lock")
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
}
|
|
||||||
|
|
||||||
unlock1()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-secondAcquired:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("second acquire should proceed after first releases the lock")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("different directories do not block", func(t *testing.T) {
|
|
||||||
dirA := t.TempDir()
|
|
||||||
dirB := t.TempDir()
|
|
||||||
|
|
||||||
unlockA := AcquireCloneLock(dirA)
|
|
||||||
defer unlockA()
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
unlock := AcquireCloneLock(dirB)
|
|
||||||
unlock()
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("acquire on a different directory must not block")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("same directory reuses the same mutex", func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
v1, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
|
||||||
v2, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
|
||||||
require.Same(t, v1, v2)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -14,13 +13,6 @@ import (
|
|||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExitCodeError reports a non-zero process exit code from a container command.
|
|
||||||
type ExitCodeError int
|
|
||||||
|
|
||||||
func (e ExitCodeError) Error() string {
|
|
||||||
return fmt.Sprintf("Process completed with exit code %d.", int(e))
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewContainerInput the input for the New function
|
// NewContainerInput the input for the New function
|
||||||
type NewContainerInput struct {
|
type NewContainerInput struct {
|
||||||
Image string
|
Image string
|
||||||
@@ -47,7 +39,6 @@ type NewContainerInput struct {
|
|||||||
// Gitea specific
|
// Gitea specific
|
||||||
AutoRemove bool
|
AutoRemove bool
|
||||||
ValidVolumes []string
|
ValidVolumes []string
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for the container's exec processes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileEntry is a file to copy to a container
|
// FileEntry is a file to copy to a container
|
||||||
|
|||||||
@@ -8,33 +8,34 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
|
||||||
"github.com/docker/cli/cli/config"
|
"github.com/docker/cli/cli/config"
|
||||||
"github.com/moby/moby/api/types/registry"
|
"github.com/docker/cli/cli/config/credentials"
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
// config.LoadDefaultConfigFile panics on nil io.Writer when the config
|
config, err := config.Load(config.Dir())
|
||||||
// file is malformed; use config.Load to route errors through the logger.
|
|
||||||
cfg, err := config.Load(config.Dir())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not load docker config: %v", err)
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
return registry.AuthConfig{}, err
|
return registry.AuthConfig{}, err
|
||||||
}
|
}
|
||||||
registryKey := registryAuthConfigKey("docker.io")
|
|
||||||
if image != "" {
|
if !config.ContainsAuth() {
|
||||||
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
|
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
||||||
logger.Warnf("Could not normalize image reference: %v", refErr)
|
|
||||||
} else {
|
|
||||||
registryKey = registryAuthConfigKey(reference.Domain(registryRef))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
authConfig, err := cfg.GetAuthConfig(registryKey)
|
hostName := "index.docker.io"
|
||||||
|
index := strings.IndexRune(image, '/')
|
||||||
|
if index > -1 && (strings.ContainsAny(image[:index], ".:") || image[:index] == "localhost") {
|
||||||
|
hostName = image[:index]
|
||||||
|
}
|
||||||
|
|
||||||
|
authConfig, err := config.GetAuthConfig(hostName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not get auth config from docker config: %v", err)
|
logger.Warnf("Could not get auth config from docker config: %v", err)
|
||||||
return registry.AuthConfig{}, err
|
return registry.AuthConfig{}, err
|
||||||
@@ -45,16 +46,17 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi
|
|||||||
|
|
||||||
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
cfg, err := config.Load(config.Dir())
|
config, err := config.Load(config.Dir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not load docker config: %v", err)
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
creds, err := cfg.GetAllCredentials()
|
|
||||||
if err != nil {
|
if !config.ContainsAuth() {
|
||||||
logger.Warnf("Could not get docker auth configs: %v", err)
|
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
creds, _ := config.GetAllCredentials()
|
||||||
authConfigs := make(map[string]registry.AuthConfig, len(creds))
|
authConfigs := make(map[string]registry.AuthConfig, len(creds))
|
||||||
for k, v := range creds {
|
for k, v := range creds {
|
||||||
authConfigs[k] = registry.AuthConfig(v)
|
authConfigs[k] = registry.AuthConfig(v)
|
||||||
@@ -62,10 +64,3 @@ func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
|||||||
|
|
||||||
return authConfigs
|
return authConfigs
|
||||||
}
|
}
|
||||||
|
|
||||||
func registryAuthConfigKey(domainName string) string {
|
|
||||||
if domainName == "docker.io" || domainName == "index.docker.io" {
|
|
||||||
return "https://index.docker.io/v1/"
|
|
||||||
}
|
|
||||||
return domainName
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,12 +14,10 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/moby/go-archive"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/moby/go-archive/compression"
|
"github.com/docker/docker/pkg/archive"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/buildkit/frontend/dockerfile/dockerignore"
|
||||||
"github.com/moby/patternmatcher"
|
"github.com/moby/patternmatcher"
|
||||||
"github.com/moby/patternmatcher/ignorefile"
|
|
||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDockerBuildExecutor function to create a run executor for the container
|
// NewDockerBuildExecutor function to create a run executor for the container
|
||||||
@@ -27,9 +25,9 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
if input.Platform != "" {
|
if input.Platform != "" {
|
||||||
logger.Infof("docker build -t %s --platform %s %s", input.ImageTag, input.Platform, input.ContextDir)
|
logger.Infof("%sdocker build -t %s --platform %s %s", logPrefix, input.ImageTag, input.Platform, input.ContextDir)
|
||||||
} else {
|
} else {
|
||||||
logger.Infof("docker build -t %s %s", input.ImageTag, input.ContextDir)
|
logger.Infof("%sdocker build -t %s %s", logPrefix, input.ImageTag, input.ContextDir)
|
||||||
}
|
}
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -44,19 +42,13 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
|||||||
logger.Debugf("Building image from '%v'", input.ContextDir)
|
logger.Debugf("Building image from '%v'", input.ContextDir)
|
||||||
|
|
||||||
tags := []string{input.ImageTag}
|
tags := []string{input.ImageTag}
|
||||||
options := client.ImageBuildOptions{
|
options := types.ImageBuildOptions{
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
|
Platform: input.Platform,
|
||||||
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||||
Dockerfile: input.Dockerfile,
|
Dockerfile: input.Dockerfile,
|
||||||
}
|
}
|
||||||
platform, err := parsePlatform(input.Platform)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if platform != nil {
|
|
||||||
options.Platforms = []specs.Platform{*platform}
|
|
||||||
}
|
|
||||||
var buildContext io.ReadCloser
|
var buildContext io.ReadCloser
|
||||||
if input.BuildContext != nil {
|
if input.BuildContext != nil {
|
||||||
buildContext = io.NopCloser(input.BuildContext)
|
buildContext = io.NopCloser(input.BuildContext)
|
||||||
@@ -84,7 +76,7 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
|||||||
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
||||||
|
|
||||||
// And canonicalize dockerfile name to a platform-independent one
|
// And canonicalize dockerfile name to a platform-independent one
|
||||||
relDockerfile = filepath.ToSlash(relDockerfile)
|
relDockerfile = archive.CanonicalTarNameForPath(relDockerfile)
|
||||||
|
|
||||||
f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
|
f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
@@ -94,7 +86,7 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
|||||||
|
|
||||||
var excludes []string
|
var excludes []string
|
||||||
if err == nil {
|
if err == nil {
|
||||||
excludes, err = ignorefile.ReadAll(f)
|
excludes, err = dockerignore.ReadAll(f) //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -114,8 +106,9 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
|||||||
includes = append(includes, ".dockerignore", relDockerfile)
|
includes = append(includes, ".dockerignore", relDockerfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
compression := archive.Uncompressed
|
||||||
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
||||||
Compression: compression.None,
|
Compression: compression,
|
||||||
ExcludePatterns: excludes,
|
ExcludePatterns: excludes,
|
||||||
IncludeFiles: includes,
|
IncludeFiles: includes,
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,18 +16,15 @@ package container
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/netip"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
networktypes "github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/google/go-cmp/cmp"
|
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
|
||||||
"github.com/moby/moby/api/types/container"
|
|
||||||
networktypes "github.com/moby/moby/api/types/network"
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
@@ -80,21 +77,21 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
|||||||
return flags, copts
|
return flags, copts
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
config, hostConfig, networkingConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
config, hostConfig, _, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
return config, hostConfig, networkingConfig
|
return config, hostConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRunLinks(t *testing.T) {
|
func TestParseRunLinks(t *testing.T) {
|
||||||
if _, hostConfig, _ := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
|
if _, hostConfig := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
|
||||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links)
|
t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links)
|
||||||
}
|
}
|
||||||
if _, hostConfig, _ := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
|
if _, hostConfig := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
|
||||||
t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links)
|
t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links)
|
||||||
}
|
}
|
||||||
if _, hostConfig, _ := mustParse(t, ""); len(hostConfig.Links) != 0 {
|
if _, hostConfig := mustParse(t, ""); len(hostConfig.Links) != 0 {
|
||||||
t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links)
|
t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +140,7 @@ func TestParseRunAttach(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.input, func(t *testing.T) {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
config, _, _ := mustParse(t, tc.input)
|
config, _ := mustParse(t, tc.input)
|
||||||
assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin)
|
assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin)
|
||||||
assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout)
|
assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout)
|
||||||
assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr)
|
assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr)
|
||||||
@@ -197,10 +194,11 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from docker/cli tests
|
//nolint:gocyclo // function handles many cases
|
||||||
|
func TestParseWithVolumes(t *testing.T) {
|
||||||
// A single volume
|
// A single volume
|
||||||
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
|
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
|
||||||
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
|
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil {
|
||||||
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
|
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
|
||||||
} else if _, exists := config.Volumes[arr[0]]; !exists {
|
} else if _, exists := config.Volumes[arr[0]]; !exists {
|
||||||
t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes)
|
t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes)
|
||||||
@@ -208,7 +206,7 @@ func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from
|
|||||||
|
|
||||||
// Two volumes
|
// Two volumes
|
||||||
arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`})
|
arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`})
|
||||||
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
|
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil {
|
||||||
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
|
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
|
||||||
} else if _, exists := config.Volumes[arr[0]]; !exists {
|
} else if _, exists := config.Volumes[arr[0]]; !exists {
|
||||||
t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes)
|
t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes)
|
||||||
@@ -218,13 +216,13 @@ func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from
|
|||||||
|
|
||||||
// A single bind mount
|
// A single bind mount
|
||||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`})
|
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`})
|
||||||
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] {
|
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] {
|
||||||
t.Fatalf("Error parsing volume flags, %q should mount-bind the path before the colon into the path after the colon. Received %v %v", arr[0], hostConfig.Binds, config.Volumes)
|
t.Fatalf("Error parsing volume flags, %q should mount-bind the path before the colon into the path after the colon. Received %v %v", arr[0], hostConfig.Binds, config.Volumes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two bind mounts.
|
// Two bind mounts.
|
||||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`})
|
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`})
|
||||||
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
||||||
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,26 +231,26 @@ func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from
|
|||||||
arr, tryit = setupPlatformVolume(
|
arr, tryit = setupPlatformVolume(
|
||||||
[]string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`},
|
[]string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`},
|
||||||
[]string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`})
|
[]string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`})
|
||||||
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
||||||
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Similar to previous test but with alternate modes which are only supported by Linux
|
// Similar to previous test but with alternate modes which are only supported by Linux
|
||||||
if runtime.GOOS != "windows" {
|
if runtime.GOOS != "windows" {
|
||||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, []string{})
|
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, []string{})
|
||||||
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
||||||
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{})
|
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{})
|
||||||
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
|
||||||
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One bind mount and one volume
|
// One bind mount and one volume
|
||||||
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`})
|
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`})
|
||||||
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] {
|
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] {
|
||||||
t.Fatalf("Error parsing volume flags, %s and %s should only one and only one bind mount %s. Received %s", arr[0], arr[1], arr[0], hostConfig.Binds)
|
t.Fatalf("Error parsing volume flags, %s and %s should only one and only one bind mount %s. Received %s", arr[0], arr[1], arr[0], hostConfig.Binds)
|
||||||
} else if _, exists := config.Volumes[arr[1]]; !exists {
|
} else if _, exists := config.Volumes[arr[1]]; !exists {
|
||||||
t.Fatalf("Error parsing volume flags %s and %s. %s is missing from volumes. Received %v", arr[0], arr[1], arr[1], config.Volumes)
|
t.Fatalf("Error parsing volume flags %s and %s. %s is missing from volumes. Received %v", arr[0], arr[1], arr[1], config.Volumes)
|
||||||
@@ -261,7 +259,7 @@ func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from
|
|||||||
// Root to non-c: drive letter (Windows specific)
|
// Root to non-c: drive letter (Windows specific)
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
arr, tryit = setupPlatformVolume([]string{}, []string{os.Getenv("SystemDrive") + `\:d:`})
|
arr, tryit = setupPlatformVolume([]string{}, []string{os.Getenv("SystemDrive") + `\:d:`})
|
||||||
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 {
|
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 {
|
||||||
t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0])
|
t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,36 +295,6 @@ func compareRandomizedStrings(a, b, c, d string) error {
|
|||||||
return errors.Errorf("strings don't match")
|
return errors.Errorf("strings don't match")
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustNetworkPort(t *testing.T, value string) networktypes.Port {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
port, err := networktypes.ParsePort(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse network port %q: %v", value, err)
|
|
||||||
}
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustAddr(t *testing.T, value string) netip.Addr {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
addr, err := netip.ParseAddr(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse address %q: %v", value, err)
|
|
||||||
}
|
|
||||||
return addr
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustAddrs(t *testing.T, values ...string) []netip.Addr {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
addrs := make([]netip.Addr, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
addrs = append(addrs, mustAddr(t, value))
|
|
||||||
}
|
|
||||||
return addrs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple parse with MacAddress validation
|
// Simple parse with MacAddress validation
|
||||||
func TestParseWithMacAddress(t *testing.T) {
|
func TestParseWithMacAddress(t *testing.T) {
|
||||||
invalidMacAddress := "--mac-address=invalidMacAddress"
|
invalidMacAddress := "--mac-address=invalidMacAddress"
|
||||||
@@ -334,10 +302,9 @@ func TestParseWithMacAddress(t *testing.T) {
|
|||||||
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
||||||
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
|
||||||
}
|
}
|
||||||
_, hostConfig, networkingConfig := mustParse(t, validMacAddress)
|
if config, _ := mustParse(t, validMacAddress); config.MacAddress != "92:d0:c6:0a:29:33" { //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
endpoint := networkingConfig.EndpointsConfig[string(hostConfig.NetworkMode)]
|
t.Fatalf("Expected the config to have '92:d0:c6:0a:29:33' as MacAddress, got '%v'", config.MacAddress) //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
assert.Check(t, endpoint != nil)
|
}
|
||||||
assert.Equal(t, "92:d0:c6:0a:29:33", endpoint.MacAddress.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunFlagsParseWithMemory(t *testing.T) {
|
func TestRunFlagsParseWithMemory(t *testing.T) {
|
||||||
@@ -346,7 +313,7 @@ func TestRunFlagsParseWithMemory(t *testing.T) {
|
|||||||
err := flags.Parse(args)
|
err := flags.Parse(args)
|
||||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
|
assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
|
||||||
|
|
||||||
_, hostconfig, _ := mustParse(t, "--memory=1G")
|
_, hostconfig := mustParse(t, "--memory=1G")
|
||||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory))
|
assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,10 +323,10 @@ func TestParseWithMemorySwap(t *testing.T) {
|
|||||||
err := flags.Parse(args)
|
err := flags.Parse(args)
|
||||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
|
assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
|
||||||
|
|
||||||
_, hostconfig, _ := mustParse(t, "--memory-swap=1G")
|
_, hostconfig := mustParse(t, "--memory-swap=1G")
|
||||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap))
|
assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap))
|
||||||
|
|
||||||
_, hostconfig, _ = mustParse(t, "--memory-swap=-1")
|
_, hostconfig = mustParse(t, "--memory-swap=-1")
|
||||||
assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap))
|
assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,14 +341,14 @@ func TestParseHostname(t *testing.T) {
|
|||||||
hostnameWithDomain := "--hostname=hostname.domainname"
|
hostnameWithDomain := "--hostname=hostname.domainname"
|
||||||
hostnameWithDomainTld := "--hostname=hostname.domainname.tld"
|
hostnameWithDomainTld := "--hostname=hostname.domainname.tld"
|
||||||
for hostname, expectedHostname := range validHostnames {
|
for hostname, expectedHostname := range validHostnames {
|
||||||
if config, _, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname {
|
if config, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname {
|
||||||
t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname)
|
t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config, _, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" {
|
if config, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" {
|
||||||
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname)
|
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname)
|
||||||
}
|
}
|
||||||
if config, _, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" {
|
if config, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" {
|
||||||
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname.tld, got %q", config.Hostname)
|
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname.tld, got %q", config.Hostname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,28 +362,26 @@ func TestParseHostnameDomainname(t *testing.T) {
|
|||||||
"domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors",
|
"domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors",
|
||||||
}
|
}
|
||||||
for domainname, expectedDomainname := range validDomainnames {
|
for domainname, expectedDomainname := range validDomainnames {
|
||||||
if config, _, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname {
|
if config, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname {
|
||||||
t.Fatalf("Expected the config to have 'domainname' as %q, got %q", expectedDomainname, config.Domainname)
|
t.Fatalf("Expected the config to have 'domainname' as %q, got %q", expectedDomainname, config.Domainname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config, _, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" {
|
if config, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" {
|
||||||
t.Fatalf("Expected the config to have 'hostname' as 'some.prefix' and 'domainname' as 'domainname', got %q and %q", config.Hostname, config.Domainname)
|
t.Fatalf("Expected the config to have 'hostname' as 'some.prefix' and 'domainname' as 'domainname', got %q and %q", config.Hostname, config.Domainname)
|
||||||
}
|
}
|
||||||
if config, _, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" {
|
if config, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" {
|
||||||
t.Fatalf("Expected the config to have 'hostname' as 'another-prefix' and 'domainname' as 'domainname.tld', got %q and %q", config.Hostname, config.Domainname)
|
t.Fatalf("Expected the config to have 'hostname' as 'another-prefix' and 'domainname' as 'domainname.tld', got %q and %q", config.Hostname, config.Domainname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWithExpose(t *testing.T) {
|
func TestParseWithExpose(t *testing.T) {
|
||||||
invalids := []string{
|
invalids := map[string]string{
|
||||||
":",
|
":": "invalid port format for --expose: :",
|
||||||
"8080:9090",
|
"8080:9090": "invalid port format for --expose: 8080:9090",
|
||||||
"/tcp",
|
"NaN/tcp": `invalid range format for --expose: NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
||||||
"/udp",
|
"NaN-NaN/tcp": `invalid range format for --expose: NaN-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
||||||
"NaN/tcp",
|
"8080-NaN/tcp": `invalid range format for --expose: 8080-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
||||||
"NaN-NaN/tcp",
|
"1234567890-8080/tcp": `invalid range format for --expose: 1234567890-8080/tcp, error: strconv.ParseUint: parsing "1234567890": value out of range`,
|
||||||
"8080-NaN/tcp",
|
|
||||||
"1234567890-8080/tcp",
|
|
||||||
}
|
}
|
||||||
valids := map[string][]nat.Port{
|
valids := map[string][]nat.Port{
|
||||||
"8080/tcp": {"8080/tcp"},
|
"8080/tcp": {"8080/tcp"},
|
||||||
@@ -425,9 +390,9 @@ func TestParseWithExpose(t *testing.T) {
|
|||||||
"8080-8080/udp": {"8080/udp"},
|
"8080-8080/udp": {"8080/udp"},
|
||||||
"8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"},
|
"8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"},
|
||||||
}
|
}
|
||||||
for _, expose := range invalids {
|
for expose, expectedError := range invalids {
|
||||||
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil {
|
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil || err.Error() != expectedError {
|
||||||
t.Fatalf("Expected error with '--expose=%v', got none", expose)
|
t.Fatalf("Expected error '%v' with '--expose=%v', got '%v'", expectedError, expose, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for expose, exposedPorts := range valids {
|
for expose, exposedPorts := range valids {
|
||||||
@@ -439,7 +404,7 @@ func TestParseWithExpose(t *testing.T) {
|
|||||||
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
|
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
|
||||||
}
|
}
|
||||||
for _, port := range exposedPorts {
|
for _, port := range exposedPorts {
|
||||||
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
if _, ok := config.ExposedPorts[port]; !ok {
|
||||||
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
|
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,7 +419,7 @@ func TestParseWithExpose(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ports := []nat.Port{"80/tcp", "81/tcp"}
|
ports := []nat.Port{"80/tcp", "81/tcp"}
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
if _, ok := config.ExposedPorts[port]; !ok {
|
||||||
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
|
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,9 +499,9 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
expected: map[string]*networktypes.EndpointSettings{
|
expected: map[string]*networktypes.EndpointSettings{
|
||||||
"net1": {
|
"net1": {
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: "172.20.88.22",
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: "2001:db8::8822",
|
||||||
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
|
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
||||||
},
|
},
|
||||||
Links: []string{"foo:bar", "bar:baz"},
|
Links: []string{"foo:bar", "bar:baz"},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
@@ -563,9 +528,9 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"net1": {
|
"net1": {
|
||||||
DriverOpts: map[string]string{"field1": "value1"},
|
DriverOpts: map[string]string{"field1": "value1"},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: "172.20.88.22",
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: "2001:db8::8822",
|
||||||
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
|
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
||||||
},
|
},
|
||||||
Links: []string{"foo:bar", "bar:baz"},
|
Links: []string{"foo:bar", "bar:baz"},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
@@ -574,8 +539,8 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"net3": {
|
"net3": {
|
||||||
DriverOpts: map[string]string{"field3": "value3"},
|
DriverOpts: map[string]string{"field3": "value3"},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: "172.20.88.22",
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: "2001:db8::8822",
|
||||||
},
|
},
|
||||||
Aliases: []string{"web3"},
|
Aliases: []string{"web3"},
|
||||||
},
|
},
|
||||||
@@ -592,8 +557,8 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"field2": "value2",
|
"field2": "value2",
|
||||||
},
|
},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: mustAddr(t, "172.20.88.22"),
|
IPv4Address: "172.20.88.22",
|
||||||
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
IPv6Address: "2001:db8::8822",
|
||||||
},
|
},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
},
|
},
|
||||||
@@ -646,9 +611,7 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
|
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode)
|
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode)
|
||||||
if diff := cmp.Diff(tc.expected, nwConfig.EndpointsConfig, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
|
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected)
|
||||||
t.Fatalf("unexpected endpoints (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -669,7 +632,7 @@ func TestParseModes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// uts ko
|
// uts ko
|
||||||
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // ignoring multiple returns in test helpers
|
||||||
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
||||||
|
|
||||||
// uts ok
|
// uts ok
|
||||||
@@ -729,9 +692,10 @@ func TestParseRestartPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
expected := "Conflicting options: --restart and --rm"
|
||||||
if err == nil {
|
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // ignoring multiple returns in test helpers
|
||||||
t.Fatal("Expected error for conflicting --restart and --rm, but got none")
|
if err == nil || err.Error() != expected {
|
||||||
|
t.Fatalf("Expected error %v, but got none", expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,7 +753,7 @@ func TestParseLoggingOpts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act
|
||||||
e := "open nonexistent: no such file or directory"
|
e := "open nonexistent: no such file or directory"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
e = "open nonexistent: The system cannot find the file specified."
|
e = "open nonexistent: The system cannot find the file specified."
|
||||||
@@ -832,7 +796,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UTF16 with BOM
|
// UTF16 with BOM
|
||||||
e := "invalid env file"
|
e := "contains invalid utf8 bytes at line"
|
||||||
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
||||||
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
t.Fatalf("Expected an error with message '%s', got %v", e, err)
|
||||||
}
|
}
|
||||||
@@ -842,7 +806,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act
|
||||||
e := "open nonexistent: no such file or directory"
|
e := "open nonexistent: no such file or directory"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
e = "open nonexistent: The system cannot find the file specified."
|
e = "open nonexistent: The system cannot find the file specified."
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/moby/moby/client"
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
@@ -23,8 +23,8 @@ func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool,
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName)
|
||||||
if cerrdefs.IsNotFound(err) {
|
if client.IsErrNotFound(err) {
|
||||||
return false, nil
|
return false, nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -46,14 +46,14 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName)
|
||||||
if cerrdefs.IsNotFound(err) {
|
if client.IsErrNotFound(err) {
|
||||||
return false, nil
|
return false, nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
|
if _, err = cli.ImageRemove(ctx, inspectImage.ID, types.ImageRemoveOptions{
|
||||||
Force: force,
|
Force: force,
|
||||||
PruneChildren: pruneChildren,
|
PruneChildren: pruneChildren,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
@@ -6,64 +6,66 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"io"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
|
||||||
// network or emulation since there is nothing to run) and returns its tag, removing it after
|
|
||||||
// the test.
|
|
||||||
func buildScratchImage(t *testing.T, platform string) string {
|
|
||||||
t.Helper()
|
|
||||||
tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/"))
|
|
||||||
cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-")
|
|
||||||
cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n")
|
|
||||||
// Force BuildKit: it records the requested architecture in the image config for a
|
|
||||||
// FROM-scratch build, whereas the classic builder ignores --platform and tags it with the
|
|
||||||
// host arch, which would break the per-platform existence assertions below.
|
|
||||||
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, string(out))
|
|
||||||
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageExistsLocally(t *testing.T) {
|
func TestImageExistsLocally(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
// to help make this test reliable and not flaky, we need to have
|
||||||
|
// an image that will exist, and onew that won't exist
|
||||||
|
|
||||||
// a non-existent image is reported absent
|
// Test if image exists with specific tag
|
||||||
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.False(t, missing)
|
assert.Equal(t, false, invalidImageTag) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// Build tiny images for two architectures locally so per-platform existence can be checked
|
// Test if image exists with specific architecture (image platform)
|
||||||
// offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network).
|
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
|
||||||
amd64Ref := buildScratchImage(t, "linux/amd64")
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
arm64Ref := buildScratchImage(t, "linux/arm64")
|
assert.Equal(t, false, invalidImagePlatform) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
|
// pull an image
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||||
assert.True(t, amd64Exists)
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
cli.NegotiateAPIVersion(context.Background())
|
||||||
|
|
||||||
// a non-host architecture image is detected for its own architecture
|
// Chose alpine latest because it's so small
|
||||||
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
|
// maybe we should build an image instead so that tests aren't reliable on dockerhub
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
readerDefault, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
|
||||||
assert.True(t, arm64Exists)
|
Platform: "linux/amd64",
|
||||||
|
})
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
defer readerDefault.Close()
|
||||||
|
_, err = io.ReadAll(readerDefault)
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// a present image is reported absent for a different platform
|
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/amd64")
|
||||||
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
|
assert.Nil(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, true, imageDefaultArchExists) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.False(t, wrongPlatform)
|
|
||||||
|
// Validate if another architecture platform can be pulled
|
||||||
|
readerArm64, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
|
||||||
|
Platform: "linux/arm64",
|
||||||
|
})
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
defer readerArm64.Close()
|
||||||
|
_, err = io.ReadAll(readerArm64)
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
imageArm64Exists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/arm64")
|
||||||
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.Equal(t, true, imageArm64Exists) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ type dockerMessage struct {
|
|||||||
Progress string `json:"progress"`
|
Progress string `json:"progress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logPrefix = " \U0001F433 "
|
||||||
|
|
||||||
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
|
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
|
||||||
if dockerResponse == nil {
|
if dockerResponse == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/moby/moby/client"
|
"github.com/docker/docker/api/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||||
@@ -23,20 +23,20 @@ func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
|||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
// Only create the network if it doesn't exist
|
// Only create the network if it doesn't exist
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
// For Gitea, reduce log noise
|
||||||
// common.Logger(ctx).Debugf("%v", networks)
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
for _, n := range networks.Items {
|
for _, network := range networks {
|
||||||
if n.Name == name {
|
if network.Name == name {
|
||||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
_, err = cli.NetworkCreate(ctx, name, types.NetworkCreate{
|
||||||
Driver: "bridge",
|
Driver: "bridge",
|
||||||
Scope: "local",
|
Scope: "local",
|
||||||
})
|
})
|
||||||
@@ -56,23 +56,23 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
// Make sure that all network of the specified name are removed
|
// Make shure that all network of the specified name are removed
|
||||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
// For Gitea, reduce log noise
|
||||||
// common.Logger(ctx).Debugf("%v", networks)
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
for _, n := range networks.Items {
|
for _, network := range networks {
|
||||||
if n.Name == name {
|
if network.Name == name {
|
||||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
result, err := cli.NetworkInspect(ctx, network.ID, types.NetworkInspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Network.Containers) == 0 {
|
if len(result.Containers) == 0 {
|
||||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
if err = cli.NetworkRemove(ctx, network.ID); err != nil {
|
||||||
common.Logger(ctx).Debugf("%v", err)
|
common.Logger(ctx).Debugf("%v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2025 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// parsePlatform parses an "os/arch[/variant]" string into a Platform. An empty input
|
|
||||||
// returns (nil, nil), meaning "no platform constraint". A non-empty but malformed
|
|
||||||
// string is rejected explicitly so it cannot silently fall through to the daemon's
|
|
||||||
// default architecture.
|
|
||||||
func parsePlatform(platform string) (*specs.Platform, error) {
|
|
||||||
if platform == "" {
|
|
||||||
return nil, nil //nolint:nilnil // no platform constraint requested
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(platform, "/")
|
|
||||||
if len(parts) < 2 || len(parts) > 3 || parts[0] == "" || parts[1] == "" || (len(parts) == 3 && parts[2] == "") {
|
|
||||||
return nil, fmt.Errorf("invalid platform %q: expected os/arch[/variant]", platform)
|
|
||||||
}
|
|
||||||
|
|
||||||
spec := &specs.Platform{
|
|
||||||
OS: strings.ToLower(parts[0]),
|
|
||||||
Architecture: strings.ToLower(parts[1]),
|
|
||||||
}
|
|
||||||
if len(parts) == 3 {
|
|
||||||
spec.Variant = strings.ToLower(parts[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
return spec, nil
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParsePlatform(t *testing.T) {
|
|
||||||
t.Run("empty input returns nil platform without error", func(t *testing.T) {
|
|
||||||
got, err := parsePlatform("")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, got)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("os/arch", func(t *testing.T) {
|
|
||||||
got, err := parsePlatform("linux/amd64")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "linux", got.OS)
|
|
||||||
assert.Equal(t, "amd64", got.Architecture)
|
|
||||||
assert.Empty(t, got.Variant)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("os/arch/variant", func(t *testing.T) {
|
|
||||||
got, err := parsePlatform("linux/arm/v7")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "linux", got.OS)
|
|
||||||
assert.Equal(t, "arm", got.Architecture)
|
|
||||||
assert.Equal(t, "v7", got.Variant)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("input is lowercased", func(t *testing.T) {
|
|
||||||
got, err := parsePlatform("Linux/AMD64/V8")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, got)
|
|
||||||
assert.Equal(t, "linux", got.OS)
|
|
||||||
assert.Equal(t, "amd64", got.Architecture)
|
|
||||||
assert.Equal(t, "v8", got.Variant)
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, bad := range []string{
|
|
||||||
"amd64",
|
|
||||||
"linux",
|
|
||||||
"linux/",
|
|
||||||
"/amd64",
|
|
||||||
"/",
|
|
||||||
"//",
|
|
||||||
"linux/arm/",
|
|
||||||
"linux/arm/v7/extra",
|
|
||||||
} {
|
|
||||||
t.Run("rejects "+bad, func(t *testing.T) {
|
|
||||||
got, err := parsePlatform(bad)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Nil(t, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,23 +8,23 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/moby/moby/api/pkg/authconfig"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/moby/moby/api/types/registry"
|
"github.com/docker/docker/api/types/registry"
|
||||||
"github.com/moby/moby/client"
|
|
||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDockerPullExecutor function to create a run executor for the container
|
// NewDockerPullExecutor function to create a run executor for the container
|
||||||
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
logger.Debugf("docker pull %v", input.Image)
|
logger.Debugf("%sdocker pull %v", logPrefix, input.Image)
|
||||||
|
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -78,29 +78,26 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (types.ImagePullOptions, error) {
|
||||||
imagePullOptions := client.ImagePullOptions{}
|
imagePullOptions := types.ImagePullOptions{
|
||||||
platform, err := parsePlatform(input.Platform)
|
Platform: input.Platform,
|
||||||
if err != nil {
|
|
||||||
return imagePullOptions, err
|
|
||||||
}
|
|
||||||
if platform != nil {
|
|
||||||
imagePullOptions.Platforms = []specs.Platform{*platform}
|
|
||||||
}
|
}
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
if input.Username != "" && input.Password != "" {
|
if input.Username != "" && input.Password != "" {
|
||||||
logger.Debugf("using authentication for docker pull")
|
logger.Debugf("using authentication for docker pull")
|
||||||
|
|
||||||
encodedAuth, err := authconfig.Encode(registry.AuthConfig{
|
authConfig := registry.AuthConfig{
|
||||||
Username: input.Username,
|
Username: input.Username,
|
||||||
Password: input.Password,
|
Password: input.Password,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
encodedJSON, err := json.Marshal(authConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return imagePullOptions, err
|
return imagePullOptions, err
|
||||||
}
|
}
|
||||||
|
|
||||||
imagePullOptions.RegistryAuth = encodedAuth
|
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||||
} else {
|
} else {
|
||||||
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
|
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,17 +108,19 @@ func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput)
|
|||||||
}
|
}
|
||||||
logger.Info("using DockerAuthConfig authentication for docker pull")
|
logger.Info("using DockerAuthConfig authentication for docker pull")
|
||||||
|
|
||||||
imagePullOptions.RegistryAuth, err = authconfig.Encode(authConfig)
|
encodedJSON, err := json.Marshal(authConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return imagePullOptions, err
|
return imagePullOptions, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
return imagePullOptions, nil
|
return imagePullOptions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanImage(ctx context.Context, imageName string) string {
|
func cleanImage(ctx context.Context, image string) string {
|
||||||
ref, err := reference.ParseAnyReference(imageName)
|
ref, err := reference.ParseAnyReference(image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Error(err)
|
common.Logger(ctx).Error(err)
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -40,13 +40,10 @@ func TestCleanImage(t *testing.T) {
|
|||||||
func TestGetImagePullOptions(t *testing.T) {
|
func TestGetImagePullOptions(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
orig := config.Dir()
|
|
||||||
t.Cleanup(func() { config.SetDir(orig) })
|
|
||||||
|
|
||||||
config.SetDir("/non-existent/docker")
|
config.SetDir("/non-existent/docker")
|
||||||
|
|
||||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, "", options.RegistryAuth, "RegistryAuth should be empty if no username or password is set") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Equal(t, "", options.RegistryAuth, "RegistryAuth should be empty if no username or password is set") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||||
@@ -54,7 +51,7 @@ func TestGetImagePullOptions(t *testing.T) {
|
|||||||
Username: "username",
|
Username: "username",
|
||||||
Password: "password",
|
Password: "password",
|
||||||
})
|
})
|
||||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
|
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
|
||||||
|
|
||||||
config.SetDir("testdata/docker-pull-options")
|
config.SetDir("testdata/docker-pull-options")
|
||||||
@@ -62,6 +59,6 @@ func TestGetImagePullOptions(t *testing.T) {
|
|||||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||||
Image: "nektos/act",
|
Image: "nektos/act",
|
||||||
})
|
})
|
||||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,32 +17,31 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
|
||||||
"dario.cat/mergo"
|
|
||||||
"github.com/Masterminds/semver"
|
"github.com/Masterminds/semver"
|
||||||
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/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/mount"
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
"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/gobwas/glob"
|
||||||
|
"github.com/imdario/mergo"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
|
||||||
"github.com/moby/moby/api/types/container"
|
|
||||||
"github.com/moby/moby/api/types/mount"
|
|
||||||
"github.com/moby/moby/api/types/network"
|
|
||||||
"github.com/moby/moby/api/types/system"
|
|
||||||
"github.com/moby/moby/client"
|
|
||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewContainer creates a reference to a container
|
// NewContainer creates a reference to a container
|
||||||
@@ -54,7 +53,7 @@ func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
|||||||
|
|
||||||
func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
|
func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
|
||||||
return common.
|
return common.
|
||||||
NewDebugExecutor("docker network connect %s %s", name, cr.input.Name).
|
NewDebugExecutor("%sdocker network connect %s %s", logPrefix, name, cr.input.Name).
|
||||||
Then(
|
Then(
|
||||||
common.NewPipelineExecutor(
|
common.NewPipelineExecutor(
|
||||||
cr.connect(),
|
cr.connect(),
|
||||||
@@ -65,13 +64,9 @@ func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
|
|||||||
|
|
||||||
func (cr *containerReference) connectToNetwork(name string, aliases []string) common.Executor {
|
func (cr *containerReference) connectToNetwork(name string, aliases []string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
_, err := cr.cli.NetworkConnect(ctx, name, client.NetworkConnectOptions{
|
return cr.cli.NetworkConnect(ctx, name, cr.input.Name, &network.EndpointSettings{
|
||||||
Container: cr.input.Name,
|
Aliases: aliases,
|
||||||
EndpointConfig: &network.EndpointSettings{
|
|
||||||
Aliases: aliases,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +74,7 @@ func (cr *containerReference) connectToNetwork(name string, aliases []string) co
|
|||||||
// API version is 1.41 and beyond
|
// API version is 1.41 and beyond
|
||||||
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
|
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
|
ver, err := cli.ServerVersion(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Panicf("Failed to get Docker API Version: %s", err)
|
logger.Panicf("Failed to get Docker API Version: %s", err)
|
||||||
return false
|
return false
|
||||||
@@ -95,7 +90,7 @@ func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) b
|
|||||||
|
|
||||||
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
||||||
return common.
|
return common.
|
||||||
NewInfoExecutor("docker create image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
|
NewInfoExecutor("%sdocker create image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
|
||||||
Then(
|
Then(
|
||||||
common.NewPipelineExecutor(
|
common.NewPipelineExecutor(
|
||||||
cr.connect(),
|
cr.connect(),
|
||||||
@@ -107,7 +102,7 @@ func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
|
|||||||
|
|
||||||
func (cr *containerReference) Start(attach bool) common.Executor {
|
func (cr *containerReference) Start(attach bool) common.Executor {
|
||||||
return common.
|
return common.
|
||||||
NewInfoExecutor("docker run image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
|
NewInfoExecutor("%sdocker run image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
|
||||||
Then(
|
Then(
|
||||||
common.NewPipelineExecutor(
|
common.NewPipelineExecutor(
|
||||||
cr.connect(),
|
cr.connect(),
|
||||||
@@ -130,7 +125,7 @@ func (cr *containerReference) Start(attach bool) common.Executor {
|
|||||||
|
|
||||||
func (cr *containerReference) Pull(forcePull bool) common.Executor {
|
func (cr *containerReference) Pull(forcePull bool) common.Executor {
|
||||||
return common.
|
return common.
|
||||||
NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
|
NewInfoExecutor("%sdocker pull image=%s platform=%s username=%s forcePull=%t", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
|
||||||
Then(
|
Then(
|
||||||
NewDockerPullExecutor(NewDockerPullExecutorInput{
|
NewDockerPullExecutor(NewDockerPullExecutorInput{
|
||||||
Image: cr.input.Image,
|
Image: cr.input.Image,
|
||||||
@@ -152,9 +147,7 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
|
|||||||
|
|
||||||
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
|
common.NewInfoExecutor("%sdocker cp src=%s dst=%s", logPrefix, srcPath, destPath),
|
||||||
cr.connect(),
|
|
||||||
cr.find(),
|
|
||||||
cr.copyDir(destPath, srcPath, useGitIgnore),
|
cr.copyDir(destPath, srcPath, useGitIgnore),
|
||||||
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
|
||||||
@@ -170,21 +163,8 @@ func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath s
|
|||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
|
return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
|
||||||
}
|
}
|
||||||
// Direct entry point (no pipeline) — revalidate cr.id ourselves.
|
a, _, err := cr.cli.CopyFromContainer(ctx, cr.id, srcPath)
|
||||||
if err := cr.connect()(ctx); err != nil {
|
return a, err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := cr.find()(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if cr.id == "" {
|
|
||||||
return nil, cr.missingContainerError("get archive %s", srcPath)
|
|
||||||
}
|
|
||||||
result, err := cr.cli.CopyFromContainer(ctx, cr.id, client.CopyFromContainerOptions{SourcePath: srcPath})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result.Content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||||
@@ -197,7 +177,7 @@ func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.
|
|||||||
|
|
||||||
func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
|
func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
common.NewInfoExecutor("docker exec cmd=[%s] user=%s workdir=%s", strings.Join(command, " "), user, workdir),
|
common.NewInfoExecutor("%sdocker exec cmd=[%s] user=%s workdir=%s", logPrefix, strings.Join(command, " "), user, workdir),
|
||||||
cr.connect(),
|
cr.connect(),
|
||||||
cr.find(),
|
cr.find(),
|
||||||
cr.exec(command, env, user, workdir),
|
cr.exec(command, env, user, workdir),
|
||||||
@@ -242,27 +222,22 @@ func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cli, err = client.New(
|
cli, err = client.NewClientWithOpts(
|
||||||
client.WithHost(helper.Host),
|
client.WithHost(helper.Host),
|
||||||
client.WithDialContext(helper.Dialer),
|
client.WithDialContext(helper.Dialer),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
cli, err = client.New(client.FromEnv)
|
cli, err = client.NewClientWithOpts(client.FromEnv)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to docker daemon: %w", err)
|
return nil, fmt.Errorf("failed to connect to docker daemon: %w", err)
|
||||||
}
|
}
|
||||||
// Best-effort API version negotiation, matching the old client.NegotiateAPIVersion
|
cli.NegotiateAPIVersion(ctx)
|
||||||
// behaviour. Ping failures here are non-fatal: the connection is exercised again on
|
|
||||||
// the first real call, and the client falls back to the daemon's default API version.
|
|
||||||
if _, err := cli.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true}); err != nil {
|
|
||||||
common.Logger(ctx).Warnf("docker daemon ping during version negotiation failed, continuing: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cli, nil
|
return cli, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHostInfo(ctx context.Context) (info system.Info, err error) {
|
func GetHostInfo(ctx context.Context) (info types.Info, err error) { //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
var cli client.APIClient
|
var cli client.APIClient
|
||||||
cli, err = GetDockerClient(ctx)
|
cli, err = GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -270,12 +245,12 @@ func GetHostInfo(ctx context.Context) (info system.Info, err error) {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
result, err := cli.Info(ctx, client.InfoOptions{})
|
info, err = cli.Info(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return info, err
|
return info, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arch fetches values from docker info and translates architecture to
|
// Arch fetches values from docker info and translates architecture to
|
||||||
@@ -327,31 +302,19 @@ func (cr *containerReference) Close() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// missingContainerError is the shared "container X does not exist" error
|
|
||||||
// used by ops that need a live cr.id.
|
|
||||||
func (cr *containerReference) missingContainerError(format string, args ...any) error {
|
|
||||||
return fmt.Errorf("container %q does not exist; cannot "+format, append([]any{cr.input.Name}, args...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) find() common.Executor {
|
func (cr *containerReference) find() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id != "" {
|
if cr.id != "" {
|
||||||
// Validate cached id; clear only on definitive NotFound so a
|
return nil
|
||||||
// transient daemon error doesn't abort cleanup pipelines.
|
|
||||||
_, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
|
|
||||||
if !cerrdefs.IsNotFound(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cr.id = ""
|
|
||||||
}
|
}
|
||||||
containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{
|
containers, err := cr.cli.ContainerList(ctx, types.ContainerListOptions{ //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
All: true,
|
All: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list containers: %w", err)
|
return fmt.Errorf("failed to list containers: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range containers.Items {
|
for _, c := range containers {
|
||||||
for _, name := range c.Names {
|
for _, name := range c.Names {
|
||||||
if name[1:] == cr.input.Name {
|
if name[1:] == cr.input.Name {
|
||||||
cr.id = c.ID
|
cr.id = c.ID
|
||||||
@@ -360,6 +323,7 @@ func (cr *containerReference) find() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cr.id = ""
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,7 +335,7 @@ func (cr *containerReference) remove() common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
_, err := cr.cli.ContainerRemove(ctx, cr.id, client.ContainerRemoveOptions{
|
err := cr.cli.ContainerRemove(ctx, cr.id, types.ContainerRemoveOptions{ //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
RemoveVolumes: true,
|
RemoveVolumes: true,
|
||||||
Force: true,
|
Force: true,
|
||||||
})
|
})
|
||||||
@@ -474,22 +438,15 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
input := cr.input
|
input := cr.input
|
||||||
exposedPorts, err := convertPortSet(input.ExposedPorts)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
portBindings, err := convertPortMap(input.PortBindings)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
config := &container.Config{
|
config := &container.Config{
|
||||||
Image: input.Image,
|
Image: input.Image,
|
||||||
WorkingDir: input.WorkingDir,
|
WorkingDir: input.WorkingDir,
|
||||||
Env: input.Env,
|
Env: input.Env,
|
||||||
ExposedPorts: exposedPorts,
|
ExposedPorts: input.ExposedPorts,
|
||||||
Tty: input.AllocatePTY,
|
Tty: isTerminal,
|
||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
// For Gitea, reduce log noise
|
||||||
// logger.Debugf("Common container.Config ==> %+v", config)
|
// logger.Debugf("Common container.Config ==> %+v", config)
|
||||||
@@ -513,9 +470,15 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
|
|
||||||
var platSpecs *specs.Platform
|
var platSpecs *specs.Platform
|
||||||
if supportsContainerImagePlatform(ctx, cr.cli) && cr.input.Platform != "" {
|
if supportsContainerImagePlatform(ctx, cr.cli) && cr.input.Platform != "" {
|
||||||
platSpecs, err = parsePlatform(cr.input.Platform)
|
desiredPlatform := strings.SplitN(cr.input.Platform, `/`, 2)
|
||||||
if err != nil {
|
|
||||||
return err
|
if len(desiredPlatform) != 2 {
|
||||||
|
return fmt.Errorf("incorrect container platform option '%s'", cr.input.Platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
platSpecs = &specs.Platform{
|
||||||
|
Architecture: desiredPlatform[1],
|
||||||
|
OS: desiredPlatform[0],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,13 +490,13 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
NetworkMode: container.NetworkMode(input.NetworkMode),
|
NetworkMode: container.NetworkMode(input.NetworkMode),
|
||||||
Privileged: input.Privileged,
|
Privileged: input.Privileged,
|
||||||
UsernsMode: container.UsernsMode(input.UsernsMode),
|
UsernsMode: container.UsernsMode(input.UsernsMode),
|
||||||
PortBindings: portBindings,
|
PortBindings: input.PortBindings,
|
||||||
AutoRemove: input.AutoRemove,
|
AutoRemove: input.AutoRemove,
|
||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
// For Gitea, reduce log noise
|
||||||
// logger.Debugf("Common container.HostConfig ==> %+v", hostConfig)
|
// logger.Debugf("Common container.HostConfig ==> %+v", hostConfig)
|
||||||
|
|
||||||
config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig)
|
config, hostConfig, err := cr.mergeContainerConfigs(ctx, config, hostConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -557,13 +520,7 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := cr.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
resp, err := cr.cli.ContainerCreate(ctx, config, hostConfig, networkingConfig, platSpecs, input.Name)
|
||||||
Config: config,
|
|
||||||
HostConfig: hostConfig,
|
|
||||||
NetworkingConfig: networkingConfig,
|
|
||||||
Platform: platSpecs,
|
|
||||||
Name: input.Name,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create container: '%w'", err)
|
return fmt.Errorf("failed to create container: '%w'", err)
|
||||||
}
|
}
|
||||||
@@ -581,7 +538,7 @@ func (cr *containerReference) extractFromImageEnv(env *map[string]string) common
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
inspect, err := cr.cli.ImageInspect(ctx, cr.input.Image)
|
inspect, _, err := cr.cli.ImageInspectWithRaw(ctx, cr.input.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
return fmt.Errorf("inspect image: %w", err)
|
return fmt.Errorf("inspect image: %w", err)
|
||||||
@@ -616,9 +573,6 @@ func (cr *containerReference) extractFromImageEnv(env *map[string]string) common
|
|||||||
|
|
||||||
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
|
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
|
||||||
return cr.missingContainerError("exec %v", cmd)
|
|
||||||
}
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
// Fix slashes when running on Windows
|
// Fix slashes when running on Windows
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
@@ -630,7 +584,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Exec command '%s'", cmd)
|
logger.Debugf("Exec command '%s'", cmd)
|
||||||
isTerminal := cr.input.AllocatePTY
|
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
@@ -648,12 +602,12 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
|||||||
}
|
}
|
||||||
logger.Debugf("Working directory '%s'", wd)
|
logger.Debugf("Working directory '%s'", wd)
|
||||||
|
|
||||||
idResp, err := cr.cli.ExecCreate(ctx, cr.id, client.ExecCreateOptions{
|
idResp, err := cr.cli.ContainerExecCreate(ctx, cr.id, types.ExecConfig{
|
||||||
User: user,
|
User: user,
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
WorkingDir: wd,
|
WorkingDir: wd,
|
||||||
Env: envList,
|
Env: envList,
|
||||||
TTY: isTerminal,
|
Tty: isTerminal,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
})
|
})
|
||||||
@@ -661,34 +615,38 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
|||||||
return fmt.Errorf("failed to create exec: %w", err)
|
return fmt.Errorf("failed to create exec: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := cr.cli.ExecAttach(ctx, idResp.ID, client.ExecAttachOptions{
|
resp, err := cr.cli.ContainerExecAttach(ctx, idResp.ID, types.ExecStartCheck{
|
||||||
TTY: isTerminal,
|
Tty: isTerminal,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to attach to exec: %w", err)
|
return fmt.Errorf("failed to attach to exec: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Close()
|
defer resp.Close()
|
||||||
|
|
||||||
err = cr.waitForCommand(ctx, isTerminal, resp.HijackedResponse, idResp, user, workdir)
|
err = cr.waitForCommand(ctx, isTerminal, resp, idResp, user, workdir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
inspectResp, err := cr.cli.ExecInspect(ctx, idResp.ID, client.ExecInspectOptions{})
|
inspectResp, err := cr.cli.ContainerExecInspect(ctx, idResp.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to inspect exec: %w", err)
|
return fmt.Errorf("failed to inspect exec: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if inspectResp.ExitCode == 0 {
|
switch inspectResp.ExitCode {
|
||||||
|
case 0:
|
||||||
return nil
|
return nil
|
||||||
|
case 127:
|
||||||
|
return fmt.Errorf("exitcode '%d': command not found, please refer to https://github.com/nektos/act/issues/107 for more information", inspectResp.ExitCode)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("exitcode '%d': failure", inspectResp.ExitCode)
|
||||||
}
|
}
|
||||||
return ExitCodeError(inspectResp.ExitCode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Executor {
|
func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
idResp, err := cr.cli.ExecCreate(ctx, cr.id, client.ExecCreateOptions{
|
idResp, err := cr.cli.ContainerExecCreate(ctx, cr.id, types.ExecConfig{
|
||||||
Cmd: []string{"id", opt},
|
Cmd: []string{"id", opt},
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
@@ -697,7 +655,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := cr.cli.ExecAttach(ctx, idResp.ID, client.ExecAttachOptions{})
|
resp, err := cr.cli.ContainerExecAttach(ctx, idResp.ID, types.ExecStartCheck{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -727,7 +685,7 @@ func (cr *containerReference) tryReadGID() common.Executor {
|
|||||||
return cr.tryReadID("-g", func(id int) { cr.GID = id })
|
return cr.tryReadID("-g", func(id int) { cr.GID = id })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
|
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp types.HijackedResponse, _ types.IDResponse, _, _ string) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
cmdResponse := make(chan error)
|
cmdResponse := make(chan error)
|
||||||
@@ -773,9 +731,6 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||||
if cr.id == "" {
|
|
||||||
return cr.missingContainerError("copy to %s", destPath)
|
|
||||||
}
|
|
||||||
// Mkdir
|
// Mkdir
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
tw := tar.NewWriter(buf)
|
tw := tar.NewWriter(buf)
|
||||||
@@ -785,18 +740,12 @@ func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string
|
|||||||
Typeflag: tar.TypeDir,
|
Typeflag: tar.TypeDir,
|
||||||
})
|
})
|
||||||
tw.Close()
|
tw.Close()
|
||||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
err := cr.cli.CopyToContainer(ctx, cr.id, "/", buf, types.CopyToContainerOptions{})
|
||||||
DestinationPath: "/",
|
|
||||||
Content: buf,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
|
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
|
||||||
}
|
}
|
||||||
// Copy Content
|
// Copy Content
|
||||||
_, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
err = cr.cli.CopyToContainer(ctx, cr.id, destPath, tarStream, types.CopyToContainerOptions{})
|
||||||
DestinationPath: destPath,
|
|
||||||
Content: tarStream,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to copy content to container: %w", err)
|
return fmt.Errorf("failed to copy content to container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -809,9 +758,6 @@ func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string
|
|||||||
|
|
||||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
|
||||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
|
||||||
}
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
tarFile, err := os.CreateTemp("", "act")
|
tarFile, err := os.CreateTemp("", "act")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -873,10 +819,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to seek tar archive: %w", err)
|
return fmt.Errorf("failed to seek tar archive: %w", err)
|
||||||
}
|
}
|
||||||
_, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
err = cr.cli.CopyToContainer(ctx, cr.id, "/", tarFile, types.CopyToContainerOptions{})
|
||||||
DestinationPath: "/",
|
|
||||||
Content: tarFile,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to copy content to container: %w", err)
|
return fmt.Errorf("failed to copy content to container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -886,9 +829,6 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
|
|||||||
|
|
||||||
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
|
||||||
return cr.missingContainerError("copy to %s", dstPath)
|
|
||||||
}
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
tw := tar.NewWriter(&buf)
|
tw := tar.NewWriter(&buf)
|
||||||
@@ -913,10 +853,7 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Extracting content to '%s'", dstPath)
|
logger.Debugf("Extracting content to '%s'", dstPath)
|
||||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
err := cr.cli.CopyToContainer(ctx, cr.id, dstPath, &buf, types.CopyToContainerOptions{})
|
||||||
DestinationPath: dstPath,
|
|
||||||
Content: &buf,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to copy content to container: %w", err)
|
return fmt.Errorf("failed to copy content to container: %w", err)
|
||||||
}
|
}
|
||||||
@@ -926,7 +863,7 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
|
|||||||
|
|
||||||
func (cr *containerReference) attach() common.Executor {
|
func (cr *containerReference) attach() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{
|
out, err := cr.cli.ContainerAttach(ctx, cr.id, types.ContainerAttachOptions{ //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
Stream: true,
|
Stream: true,
|
||||||
Stdout: true,
|
Stdout: true,
|
||||||
Stderr: true,
|
Stderr: true,
|
||||||
@@ -934,7 +871,7 @@ func (cr *containerReference) attach() common.Executor {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to attach to container: %w", err)
|
return fmt.Errorf("failed to attach to container: %w", err)
|
||||||
}
|
}
|
||||||
isTerminal := cr.input.AllocatePTY
|
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
|
|
||||||
var outWriter io.Writer
|
var outWriter io.Writer
|
||||||
outWriter = cr.input.Stdout
|
outWriter = cr.input.Stdout
|
||||||
@@ -964,7 +901,7 @@ func (cr *containerReference) start() common.Executor {
|
|||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
logger.Debugf("Starting container: %v", cr.id)
|
logger.Debugf("Starting container: %v", cr.id)
|
||||||
|
|
||||||
if _, err := cr.cli.ContainerStart(ctx, cr.id, client.ContainerStartOptions{}); err != nil {
|
if err := cr.cli.ContainerStart(ctx, cr.id, types.ContainerStartOptions{}); err != nil { //nolint:staticcheck // pre-existing issue from nektos/act
|
||||||
return fmt.Errorf("failed to start container: %w", err)
|
return fmt.Errorf("failed to start container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,16 +913,14 @@ func (cr *containerReference) start() common.Executor {
|
|||||||
func (cr *containerReference) wait() common.Executor {
|
func (cr *containerReference) wait() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
waitResult := cr.cli.ContainerWait(ctx, cr.id, client.ContainerWaitOptions{
|
statusCh, errCh := cr.cli.ContainerWait(ctx, cr.id, container.WaitConditionNotRunning)
|
||||||
Condition: container.WaitConditionNotRunning,
|
|
||||||
})
|
|
||||||
var statusCode int64
|
var statusCode int64
|
||||||
select {
|
select {
|
||||||
case err := <-waitResult.Error:
|
case err := <-errCh:
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to wait for container: %w", err)
|
return fmt.Errorf("failed to wait for container: %w", err)
|
||||||
}
|
}
|
||||||
case status := <-waitResult.Result:
|
case status := <-statusCh:
|
||||||
statusCode = status.StatusCode
|
statusCode = status.StatusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,7 +930,7 @@ func (cr *containerReference) wait() common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return ExitCodeError(statusCode)
|
return fmt.Errorf("exit with `FAILURE`: %v", statusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,7 +940,22 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
if len(cr.input.ValidVolumes) > 0 {
|
if len(cr.input.ValidVolumes) > 0 {
|
||||||
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
|
globs := make([]glob.Glob, 0, len(cr.input.ValidVolumes))
|
||||||
|
for _, v := range cr.input.ValidVolumes {
|
||||||
|
if g, err := glob.Compile(v); err != nil {
|
||||||
|
logger.Errorf("create glob from %s error: %v", v, err)
|
||||||
|
} else {
|
||||||
|
globs = append(globs, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isValid := func(v string) bool {
|
||||||
|
for _, g := range globs {
|
||||||
|
if g.Match(v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
// sanitize binds
|
// sanitize binds
|
||||||
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
|
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
|
||||||
for _, bind := range hostConfig.Binds {
|
for _, bind := range hostConfig.Binds {
|
||||||
@@ -1019,7 +969,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
sanitizedBinds = append(sanitizedBinds, bind)
|
sanitizedBinds = append(sanitizedBinds, bind)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if matcher.isValid(parsed.Source, mount.Type(parsed.Type)) {
|
if isValid(parsed.Source) {
|
||||||
sanitizedBinds = append(sanitizedBinds, bind)
|
sanitizedBinds = append(sanitizedBinds, bind)
|
||||||
} else {
|
} else {
|
||||||
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
|
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
|
||||||
@@ -1029,7 +979,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
// sanitize mounts
|
// sanitize mounts
|
||||||
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
|
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
|
||||||
for _, mt := range hostConfig.Mounts {
|
for _, mt := range hostConfig.Mounts {
|
||||||
if matcher.isValid(mt.Source, mt.Type) {
|
if isValid(mt.Source) {
|
||||||
sanitizedMounts = append(sanitizedMounts, mt)
|
sanitizedMounts = append(sanitizedMounts, mt)
|
||||||
} else {
|
} else {
|
||||||
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
||||||
@@ -1043,129 +993,3 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
|
|
||||||
return config, hostConfig
|
return config, hostConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type validVolumeMatcher struct {
|
|
||||||
allowAll bool
|
|
||||||
named []glob.Glob
|
|
||||||
host []glob.Glob
|
|
||||||
}
|
|
||||||
|
|
||||||
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
ret := validVolumeMatcher{
|
|
||||||
named: make([]glob.Glob, 0, len(validVolumes)),
|
|
||||||
host: make([]glob.Glob, 0, len(validVolumes)),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range validVolumes {
|
|
||||||
if v == "**" {
|
|
||||||
ret.allowAll = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !isHostVolumePattern(v) {
|
|
||||||
if g, err := glob.Compile(v); err != nil {
|
|
||||||
logger.Errorf("create glob from %s error: %v", v, err)
|
|
||||||
} else {
|
|
||||||
ret.named = append(ret.named, g)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
normalized, err := normalizeHostVolumePath(v)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("normalize volume pattern %s error: %v", v, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if g, err := glob.Compile(normalized); err != nil {
|
|
||||||
logger.Errorf("create glob from %s error: %v", normalized, err)
|
|
||||||
} else {
|
|
||||||
ret.host = append(ret.host, g)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool {
|
|
||||||
if m.allowAll {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if isHostVolumeSource(source, sourceType) {
|
|
||||||
normalized, err := normalizeHostVolumePath(source)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, g := range m.host {
|
|
||||||
if g.Match(normalized) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, g := range m.named {
|
|
||||||
if g.Match(source) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHostVolumePattern(pattern string) bool {
|
|
||||||
return filepath.IsAbs(pattern) ||
|
|
||||||
strings.HasPrefix(pattern, "."+string(filepath.Separator)) ||
|
|
||||||
strings.HasPrefix(pattern, ".."+string(filepath.Separator)) ||
|
|
||||||
strings.Contains(pattern, "/") ||
|
|
||||||
strings.Contains(pattern, `\`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHostVolumeSource(source string, sourceType mount.Type) bool {
|
|
||||||
if sourceType == mount.TypeBind {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if sourceType == mount.TypeVolume {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return isHostVolumePattern(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeHostVolumePath(path string) (string, error) {
|
|
||||||
abs, err := filepath.Abs(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return evalSymlinksExistingPrefix(abs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func evalSymlinksExistingPrefix(path string) (string, error) {
|
|
||||||
resolved, err := filepath.EvalSymlinks(path)
|
|
||||||
if err == nil {
|
|
||||||
return filepath.Clean(resolved), nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
current := path
|
|
||||||
var missing []string
|
|
||||||
for {
|
|
||||||
_, err := os.Lstat(current)
|
|
||||||
if err == nil {
|
|
||||||
resolved, err := filepath.EvalSymlinks(current)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
for _, name := range slices.Backward(missing) {
|
|
||||||
resolved = filepath.Join(resolved, name)
|
|
||||||
}
|
|
||||||
return filepath.Clean(resolved), nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
parent := filepath.Dir(current)
|
|
||||||
if parent == current {
|
|
||||||
return filepath.Clean(path), nil
|
|
||||||
}
|
|
||||||
missing = append(missing, filepath.Base(current))
|
|
||||||
current = parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,28 +11,24 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
mobyclient "github.com/moby/moby/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDocker(t *testing.T) {
|
func TestDocker(t *testing.T) {
|
||||||
requireDocker(t)
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
client, err := GetDockerClient(ctx)
|
client, err := GetDockerClient(ctx)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||||
@@ -70,43 +66,28 @@ func TestDocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type mockDockerClient struct {
|
type mockDockerClient struct {
|
||||||
mobyclient.APIClient
|
client.APIClient
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
func (m *mockDockerClient) ContainerExecCreate(ctx context.Context, id string, opts types.ExecConfig) (types.IDResponse, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
return args.Get(0).(types.IDResponse), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ExecAttach(ctx context.Context, id string, opts mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error) {
|
func (m *mockDockerClient) ContainerExecAttach(ctx context.Context, id string, opts types.ExecStartCheck) (types.HijackedResponse, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(mobyclient.ExecAttachResult), args.Error(1)
|
return args.Get(0).(types.HijackedResponse), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts mobyclient.ExecInspectOptions) (mobyclient.ExecInspectResult, error) {
|
func (m *mockDockerClient) ContainerExecInspect(ctx context.Context, execID string) (types.ContainerExecInspect, error) {
|
||||||
args := m.Called(ctx, execID, opts)
|
args := m.Called(ctx, execID)
|
||||||
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
return args.Get(0).(types.ContainerExecInspect), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id, path string, content io.Reader, options types.CopyToContainerOptions) error {
|
||||||
args := m.Called(ctx, containerID, opts)
|
args := m.Called(ctx, id, path, content, options)
|
||||||
return args.Get(0).(mobyclient.ContainerWaitResult)
|
return args.Error(0)
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, options mobyclient.CopyToContainerOptions) (mobyclient.CopyToContainerResult, error) {
|
|
||||||
args := m.Called(ctx, id, options)
|
|
||||||
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
|
||||||
args := m.Called(ctx, id, opts)
|
|
||||||
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
|
|
||||||
args := m.Called(ctx, opts)
|
|
||||||
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type endlessReader struct {
|
type endlessReader struct {
|
||||||
@@ -138,12 +119,10 @@ func TestDockerExecAbort(t *testing.T) {
|
|||||||
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
|
||||||
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
|
||||||
HijackedResponse: mobyclient.HijackedResponse{
|
Conn: conn,
|
||||||
Conn: conn,
|
Reader: bufio.NewReader(endlessReader{}),
|
||||||
Reader: bufio.NewReader(endlessReader{}),
|
|
||||||
},
|
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
@@ -177,14 +156,12 @@ func TestDockerExecFailure(t *testing.T) {
|
|||||||
conn := &mockConn{}
|
conn := &mockConn{}
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
|
||||||
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
|
||||||
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("ContainerExecInspect", ctx, "id").Return(types.ContainerExecInspect{
|
||||||
ExitCode: 1,
|
ExitCode: 1,
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
@@ -197,56 +174,20 @@ func TestDockerExecFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||||
var exitErr ExitCodeError
|
assert.Error(t, err, "exit with `FAILURE`: 1") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
require.ErrorAs(t, err, &exitErr)
|
|
||||||
assert.Equal(t, ExitCodeError(1), exitErr)
|
|
||||||
assert.Equal(t, "Process completed with exit code 1.", err.Error())
|
|
||||||
|
|
||||||
conn.AssertExpectations(t)
|
conn.AssertExpectations(t)
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerWaitFailure(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
statusCh := make(chan container.WaitResponse, 1)
|
|
||||||
statusCh <- container.WaitResponse{StatusCode: 2}
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
|
||||||
Return(mobyclient.ContainerWaitResult{
|
|
||||||
Result: (<-chan container.WaitResponse)(statusCh),
|
|
||||||
Error: (<-chan error)(errCh),
|
|
||||||
})
|
|
||||||
|
|
||||||
cr := &containerReference{
|
|
||||||
id: "123",
|
|
||||||
cli: client,
|
|
||||||
input: &NewContainerInput{
|
|
||||||
Image: "image",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := cr.wait()(ctx)
|
|
||||||
var exitErr ExitCodeError
|
|
||||||
require.ErrorAs(t, err, &exitErr)
|
|
||||||
assert.Equal(t, ExitCodeError(2), exitErr)
|
|
||||||
assert.Equal(t, "Process completed with exit code 2.", err.Error())
|
|
||||||
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDockerCopyTarStream(t *testing.T) {
|
func TestDockerCopyTarStream(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
||||||
return opts.DestinationPath == "/" && opts.Content != nil
|
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(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{
|
cr := &containerReference{
|
||||||
id: "123",
|
id: "123",
|
||||||
cli: client,
|
cli: client,
|
||||||
@@ -257,18 +198,20 @@ func TestDockerCopyTarStream(t *testing.T) {
|
|||||||
|
|
||||||
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
|
||||||
merr := errors.New("Failure")
|
merr := errors.New("Failure")
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
||||||
return opts.DestinationPath == "/" && opts.Content != nil
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
id: "123",
|
id: "123",
|
||||||
cli: client,
|
cli: client,
|
||||||
@@ -280,21 +223,20 @@ func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
|||||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
conn := &mockConn{}
|
||||||
|
|
||||||
merr := errors.New("Failure")
|
merr := errors.New("Failure")
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
||||||
return opts.DestinationPath == "/" && opts.Content != nil
|
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
||||||
})).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{
|
cr := &containerReference{
|
||||||
id: "123",
|
id: "123",
|
||||||
cli: client,
|
cli: client,
|
||||||
@@ -306,137 +248,10 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
|||||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
conn.AssertExpectations(t)
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
|
||||||
// daemon with a torn-down container.
|
|
||||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
|
||||||
boom := errors.New("daemon unreachable")
|
|
||||||
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
return &containerReference{id: id, cli: client, input: &NewContainerInput{Name: "job-1"}}, client
|
|
||||||
}
|
|
||||||
listOpts := mobyclient.ContainerListOptions{All: true}
|
|
||||||
inspectOpts := mobyclient.ContainerInspectOptions{}
|
|
||||||
|
|
||||||
t.Run("stale id cleared, name lookup empty", func(t *testing.T) {
|
|
||||||
cr, client := newCR("stale")
|
|
||||||
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
|
||||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, nil)
|
|
||||||
require.NoError(t, cr.find()(ctx))
|
|
||||||
assert.Empty(t, cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("stale id cleared, name lookup repopulates", func(t *testing.T) {
|
|
||||||
cr, client := newCR("stale")
|
|
||||||
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
|
||||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
|
||||||
{ID: "other", Names: []string{"/somebody-else"}},
|
|
||||||
{ID: "fresh", Names: []string{"/job-1"}},
|
|
||||||
}}, nil)
|
|
||||||
require.NoError(t, cr.find()(ctx))
|
|
||||||
assert.Equal(t, "fresh", cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("live id kept", func(t *testing.T) {
|
|
||||||
cr, client := newCR("live")
|
|
||||||
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, nil)
|
|
||||||
require.NoError(t, cr.find()(ctx))
|
|
||||||
assert.Equal(t, "live", cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("transient inspect error trusts cache", func(t *testing.T) {
|
|
||||||
cr, client := newCR("live")
|
|
||||||
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, boom)
|
|
||||||
require.NoError(t, cr.find()(ctx))
|
|
||||||
assert.Equal(t, "live", cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("list error propagates", func(t *testing.T) {
|
|
||||||
cr, client := newCR("")
|
|
||||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, boom)
|
|
||||||
require.ErrorIs(t, cr.find()(ctx), boom)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every daemon entry point fails fast with a clear, container-named
|
|
||||||
// error when no live cr.id is known.
|
|
||||||
func TestRejectsMissingContainer(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
|
||||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
|
||||||
check := func(op string, err error) {
|
|
||||||
t.Helper()
|
|
||||||
require.Error(t, err, op)
|
|
||||||
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
|
||||||
}
|
|
||||||
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
|
||||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
|
||||||
check("CopyTarStream", cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
|
||||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
|
||||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
|
||||||
check("GetContainerArchive", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
|
|
||||||
// and the Copy completes against the fresh id.
|
|
||||||
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
|
||||||
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
|
||||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
|
|
||||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
|
||||||
{ID: "fresh", Names: []string{"/job-1"}},
|
|
||||||
}}, nil)
|
|
||||||
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
|
||||||
return opts.DestinationPath == "/var/run/act"
|
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
|
||||||
|
|
||||||
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
|
||||||
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
|
||||||
assert.Equal(t, "fresh", cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDockerCopyToSymlinkPath is a regression test for gitea/runner#981. Most base images
|
|
||||||
// symlink /var/run to /run, so copying into /var/run/act traverses that symlink. The broken
|
|
||||||
// docker 29.5.1 daemon fails the extraction with "mkdirat var/run: file exists" (fixed in
|
|
||||||
// 29.5.2). Running against the daemon shipped in the dind image, this catches a bad bump.
|
|
||||||
func TestDockerCopyToSymlinkPath(t *testing.T) {
|
|
||||||
requireDocker(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
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", &bytes.Buffer{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type assert containerReference implements ExecutionsEnvironment
|
// Type assert containerReference implements ExecutionsEnvironment
|
||||||
var _ ExecutionsEnvironment = &containerReference{}
|
var _ ExecutionsEnvironment = &containerReference{}
|
||||||
|
|
||||||
@@ -512,40 +327,3 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
|
||||||
logger, _ := test.NewNullLogger()
|
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
|
||||||
|
|
||||||
base := t.TempDir()
|
|
||||||
allowed := filepath.Join(base, "allowed")
|
|
||||||
denied := filepath.Join(base, "denied")
|
|
||||||
require.NoError(t, os.MkdirAll(allowed, 0o700))
|
|
||||||
require.NoError(t, os.MkdirAll(denied, 0o700))
|
|
||||||
|
|
||||||
cr := &containerReference{
|
|
||||||
input: &NewContainerInput{
|
|
||||||
ValidVolumes: []string{filepath.Join(allowed, "**")},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied"
|
|
||||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
|
||||||
Binds: []string{escapingPath + ":/mnt"},
|
|
||||||
})
|
|
||||||
assert.Empty(t, hostConf.Binds)
|
|
||||||
|
|
||||||
linkPath := filepath.Join(allowed, "link")
|
|
||||||
if err := os.Symlink(denied, linkPath); err != nil {
|
|
||||||
t.Skipf("cannot create symlink: %v", err)
|
|
||||||
}
|
|
||||||
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
|
||||||
Binds: []string{linkPath + ":/mnt"},
|
|
||||||
})
|
|
||||||
assert.Empty(t, hostConf.Binds)
|
|
||||||
|
|
||||||
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
|
||||||
Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"},
|
|
||||||
})
|
|
||||||
assert.Empty(t, hostConf.Binds)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,19 +18,9 @@ func init() {
|
|||||||
|
|
||||||
var originalCommonSocketLocations = CommonSocketLocations
|
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) {
|
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
socketURI := "/path/to/my.socket"
|
socketURI := "/path/to/my.socket"
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
@@ -39,7 +29,7 @@ func TestGetSocketAndHostWithSocket(t *testing.T) {
|
|||||||
ret, err := GetSocketAndHost(socketURI)
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
|
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,30 +42,30 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
|
|||||||
ret, err := GetSocketAndHost("")
|
ret, err := GetSocketAndHost("")
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
|
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
|
||||||
socketURI := "/path/to/my.socket"
|
socketURI := "/path/to/my.socket"
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
defaultSocket, defaultSocketFound := socketLocation()
|
defaultSocket, defaultSocketFound := socketLocation()
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
ret, err := GetSocketAndHost(socketURI)
|
ret, err := GetSocketAndHost(socketURI)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
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, true, defaultSocketFound, "Expected to find default socket") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
|
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")
|
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
@@ -83,13 +73,13 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
|
|||||||
ret, err := GetSocketAndHost("-")
|
ret, err := GetSocketAndHost("-")
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
|
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
defaultSocket, found := socketLocation()
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
@@ -97,8 +87,8 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
|||||||
ret, err := GetSocketAndHost("")
|
ret, err := GetSocketAndHost("")
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.True(t, found, "Expected a default socket to be found")
|
assert.Equal(t, true, found, "Expected a default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(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")
|
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +97,6 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
|||||||
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
||||||
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
|
||||||
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
||||||
mySocket := mySocketFile.Name()
|
mySocket := mySocketFile.Name()
|
||||||
unixSocket := "unix://" + mySocket
|
unixSocket := "unix://" + mySocket
|
||||||
@@ -123,14 +112,13 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
|
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
|
||||||
assert.True(t, found, "Expected default socket to be found")
|
assert.Equal(t, true, found, "Expected default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(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")
|
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
mySocket := "/my/socket/path.sock"
|
mySocket := "/my/socket/path.sock"
|
||||||
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
||||||
@@ -140,7 +128,7 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
|||||||
ret, err := GetSocketAndHost(mySocket)
|
ret, err := GetSocketAndHost(mySocket)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assert.False(t, found, "Expected no default socket to be found")
|
assert.Equal(t, false, found, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
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.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
|
||||||
assert.Error(t, err, "Expected an error in invalid state")
|
assert.Error(t, err, "Expected an error in invalid state")
|
||||||
@@ -148,7 +136,6 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
|
||||||
socketURI := "unix:///path/to/my.socket"
|
socketURI := "unix:///path/to/my.socket"
|
||||||
CommonSocketLocations = []string{"/unusual", "/location"}
|
CommonSocketLocations = []string{"/unusual", "/location"}
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
@@ -160,8 +147,8 @@ func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
|||||||
// Assert
|
// Assert
|
||||||
// Default socket locations
|
// Default socket locations
|
||||||
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
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")
|
assert.Equal(t, false, found, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
// Sane default
|
// Sane default
|
||||||
assert.NoError(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(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")
|
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/moby/moby/api/types/system"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,8 +51,8 @@ func RunnerArch(ctx context.Context) string {
|
|||||||
return runtime.GOOS
|
return runtime.GOOS
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHostInfo(ctx context.Context) (info system.Info, err error) {
|
func GetHostInfo(ctx context.Context) (info types.Info, err error) {
|
||||||
return system.Info{}, nil
|
return types.Info{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/moby/moby/client"
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/volume"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||||
@@ -22,12 +23,12 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
list, err := cli.VolumeList(ctx, volume.ListOptions{Filters: filters.NewArgs()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, vol := range list.Items {
|
for _, vol := range list.Volumes {
|
||||||
if vol.Name == volumeName {
|
if vol.Name == volumeName {
|
||||||
return removeExecutor(volumeName, force)(ctx)
|
return removeExecutor(volumeName, force)(ctx)
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,7 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
|
|||||||
func removeExecutor(volume string, force bool) common.Executor {
|
func removeExecutor(volume string, force bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
logger.Debugf("docker volume rm %s", volume)
|
logger.Debugf("%sdocker volume rm %s", logPrefix, volume)
|
||||||
|
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -53,7 +54,6 @@ func removeExecutor(volume string, force bool) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
return cli.VolumeRemove(ctx, volume, force)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mobyclient "github.com/moby/moby/client"
|
|
||||||
)
|
|
||||||
|
|
||||||
// requireDocker skips the test unless a reachable docker daemon is available.
|
|
||||||
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
|
||||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
|
||||||
func requireDocker(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
|
||||||
cli, err := GetDockerClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
|
||||||
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -35,13 +34,9 @@ type HostEnvironment struct {
|
|||||||
TmpDir string
|
TmpDir string
|
||||||
ToolCache string
|
ToolCache string
|
||||||
Workdir string
|
Workdir string
|
||||||
// CleanWorkdir means teardown owns Workdir and may delete it. Leave false
|
ActPath string
|
||||||
// when Workdir points at a caller-owned checkout (e.g. `act` local mode).
|
CleanUp func()
|
||||||
CleanWorkdir bool
|
StdOut io.Writer
|
||||||
ActPath string
|
|
||||||
CleanUp func()
|
|
||||||
StdOut io.Writer
|
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||||
@@ -197,12 +192,12 @@ func (e *HostEnvironment) Start(_ bool) common.Executor {
|
|||||||
|
|
||||||
type ptyWriter struct {
|
type ptyWriter struct {
|
||||||
Out io.Writer
|
Out io.Writer
|
||||||
AutoStop atomic.Bool
|
AutoStop bool
|
||||||
dirtyLine bool
|
dirtyLine bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *ptyWriter) Write(buf []byte) (int, error) {
|
func (w *ptyWriter) Write(buf []byte) (int, error) {
|
||||||
if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
if w.AutoStop && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
||||||
n, err := w.Out.Write(buf[:len(buf)-1])
|
n, err := w.Out.Write(buf[:len(buf)-1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
@@ -322,30 +317,6 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
cmd.Stderr = e.StdOut
|
cmd.Stderr = e.StdOut
|
||||||
cmd.Dir = wd
|
cmd.Dir = wd
|
||||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
||||||
|
|
||||||
// On Windows a step often launches a process tree (a shell that starts a
|
|
||||||
// child which spawns further GUI or background processes). The default
|
|
||||||
// context cancellation only kills the direct child, leaving the rest of the
|
|
||||||
// tree running; and because the orphans inherit cmd's stdout/stderr pipe,
|
|
||||||
// cmd.Wait() would block forever, hanging the runner. Kill the whole tree
|
|
||||||
// via a Job Object on cancellation, and bound the wait so a leftover pipe
|
|
||||||
// writer can never hang Wait indefinitely.
|
|
||||||
var killer atomic.Pointer[processKiller]
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
cmd.Cancel = func() error {
|
|
||||||
if k := killer.Load(); k != nil {
|
|
||||||
return k.Kill()
|
|
||||||
}
|
|
||||||
if cmd.Process != nil {
|
|
||||||
return cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Once the step process has exited, give its I/O pipes at most this long
|
|
||||||
// to drain before Wait force-closes them and returns (Go's WaitDelay).
|
|
||||||
cmd.WaitDelay = 10 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
var ppty *os.File
|
var ppty *os.File
|
||||||
var tty *os.File
|
var tty *os.File
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -356,51 +327,36 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
tty.Close()
|
tty.Close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if e.AllocatePTY {
|
if true /* allocate Terminal */ {
|
||||||
var err error
|
var err error
|
||||||
ppty, tty, err = setupPty(cmd, cmdline)
|
ppty, tty, err = setupPty(cmd, cmdline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
|
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var writer *ptyWriter
|
writer := &ptyWriter{Out: e.StdOut}
|
||||||
var logctx context.Context
|
logctx, finishLog := context.WithCancel(context.Background())
|
||||||
if ppty != nil {
|
if ppty != nil {
|
||||||
writer = &ptyWriter{Out: e.StdOut}
|
|
||||||
var finishLog context.CancelFunc
|
|
||||||
logctx, finishLog = context.WithCancel(context.Background())
|
|
||||||
go copyPtyOutput(writer, ppty, finishLog)
|
go copyPtyOutput(writer, ppty, finishLog)
|
||||||
|
} else {
|
||||||
|
finishLog()
|
||||||
|
}
|
||||||
|
if ppty != nil {
|
||||||
go writeKeepAlive(ppty)
|
go writeKeepAlive(ppty)
|
||||||
}
|
}
|
||||||
if err := cmd.Start(); err != nil {
|
err = cmd.Run()
|
||||||
return err
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
// Assign the started process to a Job Object so cmd.Cancel can kill the
|
|
||||||
// whole descendant tree. Children spawned afterwards are auto-included.
|
|
||||||
// On failure (e.g. nested-job restrictions) we fall back to the default
|
|
||||||
// single-process kill; WaitDelay + end-of-job cleanup still apply.
|
|
||||||
if k, kerr := newProcessKiller(cmd.Process); kerr != nil {
|
|
||||||
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
|
||||||
} else {
|
|
||||||
killer.Store(k)
|
|
||||||
defer k.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = cmd.Wait()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var exitErr *exec.ExitError
|
|
||||||
if errors.As(err, &exitErr) {
|
|
||||||
return ExitCodeError(exitErr.ExitCode())
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tty != nil {
|
if tty != nil {
|
||||||
writer.AutoStop.Store(true)
|
writer.AutoStop = true
|
||||||
if _, err := tty.WriteString("\x04"); err != nil {
|
if _, err := tty.WriteString("\x04"); err != nil {
|
||||||
common.Logger(ctx).Debug("Failed to write EOT")
|
common.Logger(ctx).Debug("Failed to write EOT")
|
||||||
}
|
}
|
||||||
<-logctx.Done()
|
}
|
||||||
|
<-logctx.Done()
|
||||||
|
|
||||||
|
if ppty != nil {
|
||||||
ppty.Close()
|
ppty.Close()
|
||||||
ppty = nil
|
ppty = nil
|
||||||
}
|
}
|
||||||
@@ -429,139 +385,12 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
|
|||||||
return parseEnvFile(e, srcPath, env)
|
return parseEnvFile(e, srcPath, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removePathWithRetry(ctx context.Context, path string) error {
|
|
||||||
if path == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
attempts := 1
|
|
||||||
delay := time.Duration(0)
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
attempts = 5
|
|
||||||
delay = 200 * time.Millisecond
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
for i := 0; i < attempts; i++ {
|
|
||||||
if i > 0 {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(delay):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastErr = os.RemoveAll(path)
|
|
||||||
if lastErr == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildWindowsWorkspaceKillScript builds a PowerShell command that `taskkill
|
|
||||||
// /T /F`s every process tree whose ExecutablePath or CommandLine references one
|
|
||||||
// of the given absolute workspace dirs, releasing file handles for cleanup.
|
|
||||||
//
|
|
||||||
// Win32_Process is used because it exposes both ExecutablePath and CommandLine
|
|
||||||
// (Get-Process doesn't, wmic is deprecated). Both match the dir+separator
|
|
||||||
// prefix, so a sibling dir sharing a name prefix (job1 vs job10) is spared.
|
|
||||||
// Ordinal String methods, not -like, so path metacharacters ([ ] ? *) stay
|
|
||||||
// literal.
|
|
||||||
//
|
|
||||||
// Pure function so the quote-escaping can be unit-tested without PowerShell.
|
|
||||||
func buildWindowsWorkspaceKillScript(dirs []string) string {
|
|
||||||
quoted := make([]string, len(dirs))
|
|
||||||
for i, d := range dirs {
|
|
||||||
// Single-quoted PowerShell literal; escape ' by doubling it.
|
|
||||||
quoted[i] = "'" + strings.ReplaceAll(d, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
return `$paths = @(` + strings.Join(quoted, ",") + `)
|
|
||||||
$selfPid = $PID
|
|
||||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
|
|
||||||
if ($_.ProcessId -eq $selfPid) { return $false }
|
|
||||||
foreach ($p in $paths) {
|
|
||||||
$prefix = $p + '\'
|
|
||||||
if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
|
|
||||||
if ($_.CommandLine -and $_.CommandLine.IndexOf($prefix, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
|
|
||||||
}
|
|
||||||
return $false
|
|
||||||
} | ForEach-Object {
|
|
||||||
& taskkill.exe /PID $_.ProcessId /T /F 2>$null | Out-Null
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detached: exec.CommandContext won't start on a cancelled ctx, and a
|
|
||||||
// server cancel has already cancelled the parent ctx.
|
|
||||||
killCtx, killCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer killCancel()
|
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
// Workspace dirs we own. Any process running from or referencing one is a
|
|
||||||
// leftover job process. ToolCache is shared across jobs; Workdir only when
|
|
||||||
// we own it (else it's a caller-provided checkout, e.g. act local mode).
|
|
||||||
owned := []string{e.Path, e.TmpDir}
|
|
||||||
if e.CleanWorkdir {
|
|
||||||
owned = append(owned, e.Workdir)
|
|
||||||
}
|
|
||||||
dirs := make([]string, 0, len(owned))
|
|
||||||
for _, d := range owned {
|
|
||||||
if d == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
abs, err := filepath.Abs(d)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dirs = append(dirs, abs)
|
|
||||||
}
|
|
||||||
if len(dirs) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
script := buildWindowsWorkspaceKillScript(dirs)
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(killCtx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *HostEnvironment) Remove() common.Executor {
|
func (e *HostEnvironment) Remove() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
// Ensure any lingering child processes are ended before attempting
|
|
||||||
// to remove the workspace (Windows file locks otherwise prevent cleanup).
|
|
||||||
e.terminateRunningProcesses(ctx)
|
|
||||||
|
|
||||||
// Only removes per-job misc state. Must not remove the cache/toolcache root.
|
|
||||||
if e.CleanUp != nil {
|
if e.CleanUp != nil {
|
||||||
e.CleanUp()
|
e.CleanUp()
|
||||||
}
|
}
|
||||||
|
return os.RemoveAll(e.Path)
|
||||||
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
|
|
||||||
// which absorb Windows file-handle release lag after the kill above.
|
|
||||||
rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer rmCancel()
|
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
var errs []error
|
|
||||||
if err := removePathWithRetry(rmCtx, e.Path); err != nil {
|
|
||||||
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
if e.CleanWorkdir {
|
|
||||||
if err := removePathWithRetry(rmCtx, e.Workdir); err != nil {
|
|
||||||
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors.Join(errs...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,21 +6,14 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Type assert HostEnvironment implements ExecutionsEnvironment
|
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||||
@@ -76,175 +69,3 @@ func TestGetContainerArchive(t *testing.T) {
|
|||||||
_, err = reader.Next()
|
_, err = reader.Next()
|
||||||
assert.ErrorIs(t, err, io.EOF)
|
assert.ErrorIs(t, err, io.EOF)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostEnvironmentExecExitCode(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("uses POSIX shell")
|
|
||||||
}
|
|
||||||
dir := t.TempDir()
|
|
||||||
ctx := context.Background()
|
|
||||||
e := &HostEnvironment{
|
|
||||||
Path: filepath.Join(dir, "path"),
|
|
||||||
TmpDir: filepath.Join(dir, "tmp"),
|
|
||||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
|
||||||
ActPath: filepath.Join(dir, "act_path"),
|
|
||||||
StdOut: io.Discard,
|
|
||||||
Workdir: filepath.Join(dir, "path"),
|
|
||||||
}
|
|
||||||
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
|
|
||||||
assert.NoError(t, os.MkdirAll(p, 0o700)) //nolint:testifylint // test setup
|
|
||||||
}
|
|
||||||
|
|
||||||
err := e.Exec([]string{"sh", "-c", "exit 3"}, map[string]string{"PATH": os.Getenv("PATH")}, "", "")(ctx)
|
|
||||||
var exitErr ExitCodeError
|
|
||||||
require.ErrorAs(t, err, &exitErr)
|
|
||||||
assert.Equal(t, ExitCodeError(3), exitErr)
|
|
||||||
assert.Equal(t, "Process completed with exit code 3.", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostEnvironmentAllocatePTY(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("uses POSIX shell")
|
|
||||||
}
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
allocPTY bool
|
|
||||||
expect string
|
|
||||||
}{
|
|
||||||
{name: "off", allocPTY: false, expect: "NOTTY"},
|
|
||||||
{name: "on", allocPTY: true, expect: "TTY"},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
e := &HostEnvironment{
|
|
||||||
Path: filepath.Join(dir, "path"),
|
|
||||||
TmpDir: filepath.Join(dir, "tmp"),
|
|
||||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
|
||||||
ActPath: filepath.Join(dir, "act_path"),
|
|
||||||
StdOut: buf,
|
|
||||||
Workdir: filepath.Join(dir, "path"),
|
|
||||||
AllocatePTY: tc.allocPTY,
|
|
||||||
}
|
|
||||||
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
|
|
||||||
require.NoError(t, os.MkdirAll(p, 0o700))
|
|
||||||
}
|
|
||||||
|
|
||||||
err := e.Exec(
|
|
||||||
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
|
|
||||||
map[string]string{"PATH": os.Getenv("PATH")}, "", "",
|
|
||||||
)(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
|
|
||||||
assert.Equal(t, tc.expect, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
|
|
||||||
logger := logrus.New()
|
|
||||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
|
||||||
base := t.TempDir()
|
|
||||||
miscRoot := filepath.Join(base, "misc")
|
|
||||||
path := filepath.Join(miscRoot, "hostexecutor")
|
|
||||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
|
||||||
workdir := filepath.Join(base, "workspace", "owner", "repo")
|
|
||||||
require.NoError(t, os.MkdirAll(workdir, 0o700))
|
|
||||||
|
|
||||||
e := &HostEnvironment{
|
|
||||||
Path: path,
|
|
||||||
Workdir: workdir,
|
|
||||||
CleanUp: func() {
|
|
||||||
_ = os.RemoveAll(miscRoot)
|
|
||||||
},
|
|
||||||
StdOut: os.Stdout,
|
|
||||||
}
|
|
||||||
require.NoError(t, e.Remove()(ctx))
|
|
||||||
_, err := os.Stat(workdir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
|
||||||
logger := logrus.New()
|
|
||||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
|
||||||
base := t.TempDir()
|
|
||||||
miscRoot := filepath.Join(base, "misc")
|
|
||||||
path := filepath.Join(miscRoot, "hostexecutor")
|
|
||||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
|
||||||
workdir := filepath.Join(base, "workspace", "123", "owner", "repo")
|
|
||||||
require.NoError(t, os.MkdirAll(workdir, 0o700))
|
|
||||||
|
|
||||||
e := &HostEnvironment{
|
|
||||||
Path: path,
|
|
||||||
Workdir: workdir,
|
|
||||||
CleanWorkdir: true,
|
|
||||||
CleanUp: func() {
|
|
||||||
_ = os.RemoveAll(miscRoot)
|
|
||||||
},
|
|
||||||
StdOut: os.Stdout,
|
|
||||||
}
|
|
||||||
require.NoError(t, e.Remove()(ctx))
|
|
||||||
_, err := os.Stat(workdir)
|
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
|
|
||||||
t.Run("single dir", func(t *testing.T) {
|
|
||||||
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
|
|
||||||
assert.Contains(t, s, `$paths = @('C:\workspace\job1')`)
|
|
||||||
// Self-PID guard is essential — without it the script could taskkill
|
|
||||||
// the PowerShell process running it.
|
|
||||||
assert.Contains(t, s, "$selfPid = $PID")
|
|
||||||
assert.Contains(t, s, "$_.ProcessId -eq $selfPid")
|
|
||||||
// Must match both ExecutablePath (binaries from the workspace) and
|
|
||||||
// CommandLine (system binaries invoked with workspace paths in args),
|
|
||||||
// both bounded by dir+separator so a name-prefix sibling is spared.
|
|
||||||
assert.Contains(t, s, `$prefix = $p + '\'`)
|
|
||||||
assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix")
|
|
||||||
assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix")
|
|
||||||
// Each matched PID must be tree-killed, not just stopped.
|
|
||||||
assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("multiple dirs comma-separated", func(t *testing.T) {
|
|
||||||
s := buildWindowsWorkspaceKillScript([]string{
|
|
||||||
`C:\work\path`,
|
|
||||||
`C:\work\workdir`,
|
|
||||||
`C:\Users\runner\AppData\Local\Temp\job-42`,
|
|
||||||
})
|
|
||||||
assert.Contains(t, s, `'C:\work\path'`)
|
|
||||||
assert.Contains(t, s, `'C:\work\workdir'`)
|
|
||||||
assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`)
|
|
||||||
// Commas between entries — no trailing comma, no leading comma.
|
|
||||||
assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("path with single quote is escaped", func(t *testing.T) {
|
|
||||||
// In PowerShell single-quoted strings the only special char is the
|
|
||||||
// quote itself, escaped by doubling. A workspace path that ever
|
|
||||||
// contained `'` would inject a command into the script otherwise.
|
|
||||||
s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`})
|
|
||||||
assert.Contains(t, s, `'C:\work\it''s\path'`)
|
|
||||||
// And it must NOT appear unescaped — otherwise the quote would
|
|
||||||
// terminate the literal early.
|
|
||||||
assert.NotContains(t, s, `'C:\work\it's\path'`)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) {
|
|
||||||
// A path containing [ ] ? * must be embedded verbatim and matched with
|
|
||||||
// ordinal String methods, not -like, otherwise the metacharacters would
|
|
||||||
// be interpreted as wildcards and the leftover process could escape.
|
|
||||||
s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`})
|
|
||||||
assert.Contains(t, s, `'C:\work\[job]?1'`)
|
|
||||||
assert.NotContains(t, s, "-like")
|
|
||||||
assert.Contains(t, s, "StartsWith")
|
|
||||||
assert.Contains(t, s, "IndexOf")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty dir list still produces a valid script", func(t *testing.T) {
|
|
||||||
s := buildWindowsWorkspaceKillScript(nil)
|
|
||||||
// Empty array literal — script runs, matches nothing, is a no-op.
|
|
||||||
assert.Contains(t, s, "$paths = @()")
|
|
||||||
assert.Contains(t, s, "Get-CimInstance Win32_Process")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s := bufio.NewScanner(reader)
|
s := bufio.NewScanner(reader)
|
||||||
// Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB.
|
|
||||||
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
line := s.Text()
|
line := s.Text()
|
||||||
singleLineEnv := strings.Index(line, "=")
|
singleLineEnv := strings.Index(line, "=")
|
||||||
@@ -52,9 +50,6 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
}
|
}
|
||||||
multiLineEnvContent += content
|
multiLineEnvContent += content
|
||||||
}
|
}
|
||||||
if err := s.Err(); err != nil {
|
|
||||||
return fmt.Errorf("reading env file: %w", err)
|
|
||||||
}
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -63,9 +58,6 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.Err(); err != nil {
|
|
||||||
return fmt.Errorf("reading env file: %w", err)
|
|
||||||
}
|
|
||||||
env = &localEnv
|
env = &localEnv
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
|
|
||||||
t.Helper()
|
|
||||||
e := &HostEnvironment{Path: t.TempDir()}
|
|
||||||
return e, filepath.Join(e.Path, "envfile")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseEnvFileSingleLine(t *testing.T) {
|
|
||||||
e, envPath := newTestHostEnv(t)
|
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
|
|
||||||
|
|
||||||
env := map[string]string{}
|
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
|
||||||
assert.Equal(t, "bar", env["FOO"])
|
|
||||||
assert.Equal(t, "qux", env["BAZ"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseEnvFileMultiLine(t *testing.T) {
|
|
||||||
e, envPath := newTestHostEnv(t)
|
|
||||||
content := "FOO<<EOF\nline1\nline2\nEOF\n"
|
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
|
||||||
|
|
||||||
env := map[string]string{}
|
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
|
||||||
assert.Equal(t, "line1\nline2", env["FOO"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseEnvFileLargeValueWithinLimit(t *testing.T) {
|
|
||||||
e, envPath := newTestHostEnv(t)
|
|
||||||
big := strings.Repeat("x", 2*1024*1024)
|
|
||||||
content := "FOO<<EOF\n" + big + "\nEOF\n"
|
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
|
||||||
|
|
||||||
env := map[string]string{}
|
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
|
||||||
assert.Equal(t, big, env["FOO"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
|
||||||
e, envPath := newTestHostEnv(t)
|
|
||||||
tooBig := strings.Repeat("x", 17*1024*1024) // over the 16 MiB cap
|
|
||||||
content := "FOO<<EOF\n" + tooBig + "\nEOF\n"
|
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
|
||||||
|
|
||||||
env := map[string]string{}
|
|
||||||
err := parseEnvFile(e, envPath, &env)(context.Background())
|
|
||||||
require.ErrorIs(t, err, bufio.ErrTooLong)
|
|
||||||
assert.Contains(t, err.Error(), "reading env file")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseEnvFileMissingDelimiter(t *testing.T) {
|
|
||||||
e, envPath := newTestHostEnv(t)
|
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
|
|
||||||
|
|
||||||
env := map[string]string{}
|
|
||||||
err := parseEnvFile(e, envPath, &env)(context.Background())
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "delimiter")
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
//go:build !windows
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import "os"
|
|
||||||
|
|
||||||
// processKiller is a no-op on non-Windows platforms. The Job Object based
|
|
||||||
// tree-kill is only wired in on Windows (see exec()); elsewhere the default
|
|
||||||
// exec.CommandContext cancellation and Setpgid handling apply.
|
|
||||||
type processKiller struct{}
|
|
||||||
|
|
||||||
func newProcessKiller(_ *os.Process) (*processKiller, error) { return &processKiller{}, nil }
|
|
||||||
|
|
||||||
func (k *processKiller) Kill() error { return nil }
|
|
||||||
|
|
||||||
func (k *processKiller) Close() error { return nil }
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
// processKiller terminates a step process together with its entire descendant
|
|
||||||
// tree via a Windows Job Object.
|
|
||||||
//
|
|
||||||
// Background: a step often launches a process tree (a shell that starts a
|
|
||||||
// child which in turn spawns further GUI or background processes). The default
|
|
||||||
// exec.CommandContext cancellation only kills the direct child, so cancelling a
|
|
||||||
// job left the rest of the tree running. Because those orphans inherited the
|
|
||||||
// step's stdout/stderr pipe, cmd.Wait() also blocked forever and the runner hung.
|
|
||||||
//
|
|
||||||
// Assigning the step process to a Job Object lets us kill the whole tree
|
|
||||||
// atomically on cancellation (TerminateJobObject), which also closes the
|
|
||||||
// inherited pipe handles so cmd.Wait() can return.
|
|
||||||
type processKiller struct {
|
|
||||||
job windows.Handle
|
|
||||||
}
|
|
||||||
|
|
||||||
// newProcessKiller creates a Job Object and assigns p (an already-started
|
|
||||||
// process) to it. Children spawned by p afterwards are automatically part of
|
|
||||||
// the job. The job does NOT use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so closing
|
|
||||||
// the handle on normal completion does not kill legitimate background
|
|
||||||
// processes; the tree is only torn down by an explicit Kill (cancellation).
|
|
||||||
func newProcessKiller(p *os.Process) (*processKiller, error) {
|
|
||||||
job, err := windows.CreateJobObject(nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
|
||||||
if err != nil {
|
|
||||||
windows.CloseHandle(job)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer windows.CloseHandle(h)
|
|
||||||
|
|
||||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
|
||||||
windows.CloseHandle(job)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &processKiller{job: job}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kill terminates every process currently assigned to the job (the step process
|
|
||||||
// and all of its descendants).
|
|
||||||
func (k *processKiller) Kill() error {
|
|
||||||
if k == nil || k.job == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return windows.TerminateJobObject(k.job, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close releases the job handle. It does not terminate the processes.
|
|
||||||
func (k *processKiller) Close() error {
|
|
||||||
if k == nil || k.job == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
h := k.job
|
|
||||||
k.job = 0
|
|
||||||
return windows.CloseHandle(h)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
// processAlive reports whether pid refers to a still-running process.
|
|
||||||
func processAlive(pid int) bool {
|
|
||||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer windows.CloseHandle(h)
|
|
||||||
var code uint32
|
|
||||||
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const stillActive = 259 // STILL_ACTIVE
|
|
||||||
return code == stillActive
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProcessKillerKillsTree verifies that a process assigned to the Job Object
|
|
||||||
// is terminated together with a child it spawns afterwards. This mirrors a step
|
|
||||||
// that launches a child which spawns further processes, where cancelling the
|
|
||||||
// job must take down the whole tree, not just the direct child.
|
|
||||||
func TestProcessKillerKillsTree(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
pidFile := filepath.Join(dir, "child.pid")
|
|
||||||
|
|
||||||
// Parent powershell spawns a detached, long-lived child powershell (writing
|
|
||||||
// its PID to a file) and then sleeps. The child is launched AFTER the parent
|
|
||||||
// has been assigned to the job, so it must be captured by the job too.
|
|
||||||
script := fmt.Sprintf(
|
|
||||||
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
|
|
||||||
`Set-Content -LiteralPath %q -Value $c.Id; Start-Sleep -Seconds 600`, pidFile)
|
|
||||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script)
|
|
||||||
require.NoError(t, cmd.Start())
|
|
||||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
|
||||||
|
|
||||||
killer, err := newProcessKiller(cmd.Process)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer killer.Close()
|
|
||||||
|
|
||||||
// Wait for the child PID to be reported.
|
|
||||||
var childPID int
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
b, e := os.ReadFile(pidFile)
|
|
||||||
if e != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
s := strings.TrimSpace(string(b))
|
|
||||||
if s == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
childPID, _ = strconv.Atoi(s)
|
|
||||||
return childPID > 0 && processAlive(childPID)
|
|
||||||
}, 20*time.Second, 200*time.Millisecond, "child process should start")
|
|
||||||
|
|
||||||
// Killing the job must terminate both the parent and the detached child.
|
|
||||||
require.NoError(t, killer.Kill())
|
|
||||||
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
return !processAlive(cmd.Process.Pid) && !processAlive(childPID)
|
|
||||||
}, 20*time.Second, 200*time.Millisecond, "parent and child should both be terminated")
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,7 @@ func TestFunctionContains(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
@@ -72,7 +72,7 @@ func TestFunctionStartsWith(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
@@ -101,7 +101,7 @@ func TestFunctionEndsWith(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
@@ -128,7 +128,7 @@ func TestFunctionJoin(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
@@ -154,7 +154,7 @@ func TestFunctionToJSON(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
@@ -177,7 +177,7 @@ func TestFunctionFromJSON(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
@@ -205,9 +205,9 @@ func TestFunctionHashFiles(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
workdir, err := filepath.Abs("testdata")
|
workdir, err := filepath.Abs("testdata")
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
@@ -248,7 +248,7 @@ func TestFunctionFormat(t *testing.T) {
|
|||||||
if tt.error != nil {
|
if tt.error != nil {
|
||||||
assert.Equal(t, tt.error, err.Error())
|
assert.Equal(t, tt.error, err.Error())
|
||||||
} else {
|
} else {
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
|
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
|
||||||
switch strings.ToLower(variableNode.Name) {
|
switch strings.ToLower(variableNode.Name) {
|
||||||
case "github":
|
case "github":
|
||||||
@@ -251,7 +252,7 @@ func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.Array
|
|||||||
|
|
||||||
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
|
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
|
||||||
switch left.Kind() {
|
switch left.Kind() {
|
||||||
case reflect.Pointer:
|
case reflect.Ptr:
|
||||||
return impl.getPropertyValue(left.Elem(), property)
|
return impl.getPropertyValue(left.Elem(), property)
|
||||||
|
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
@@ -321,7 +322,7 @@ func (impl *interperterImpl) getPropertyValue(left reflect.Value, property strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
|
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
|
||||||
if value.Kind() == reflect.Pointer {
|
if value.Kind() == reflect.Ptr {
|
||||||
return impl.getMapValue(value.Elem())
|
return impl.getMapValue(value.Elem())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,6 +584,7 @@ func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.Logi
|
|||||||
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
|
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
|
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
|
||||||
args := make([]reflect.Value, 0)
|
args := make([]reflect.Value, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestLiterals(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
@@ -105,10 +105,10 @@ func TestOperators(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
if tt.error != "" {
|
if tt.error != "" {
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NotNil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, tt.error, err.Error())
|
assert.Equal(t, tt.error, err.Error())
|
||||||
} else {
|
} else {
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
@@ -157,7 +157,7 @@ func TestOperatorsCompare(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
@@ -520,7 +520,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
|
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
|
||||||
assert.True(t, math.IsNaN(output.(float64)))
|
assert.True(t, math.IsNaN(output.(float64)))
|
||||||
@@ -624,7 +624,7 @@ func TestContexts(t *testing.T) {
|
|||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, tt.expected, output)
|
assert.Equal(t, tt.expected, output)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -73,16 +73,10 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
|
|||||||
if err := os.MkdirAll(filepath.Dir(fdestpath), 0o777); err != nil {
|
if err := os.MkdirAll(filepath.Dir(fdestpath), 0o777); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Remove any existing destination so we can overwrite read-only files
|
|
||||||
// (e.g. git pack files at mode 0444 trip EACCES on macOS and "Access is
|
|
||||||
// denied" on Windows when reopened with O_WRONLY) and so os.Symlink does
|
|
||||||
// not fail with EEXIST. os.Remove clears the Windows read-only attribute
|
|
||||||
// internally; on Unix unlink only needs write permission on the parent.
|
|
||||||
_ = os.Remove(fdestpath)
|
|
||||||
if f == nil {
|
if f == nil {
|
||||||
return os.Symlink(linkName, fdestpath)
|
return os.Symlink(linkName, fdestpath)
|
||||||
}
|
}
|
||||||
df, err := os.OpenFile(fdestpath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fi.Mode())
|
df, err := os.OpenFile(fdestpath, os.O_CREATE|os.O_WRONLY, fi.Mode())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -134,6 +128,7 @@ func (*DefaultFs) Readlink(path string) (string, error) {
|
|||||||
return os.Readlink(path)
|
return os.Readlink(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
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, _ := fc.Fs.OpenGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
|
||||||
return func(file string, fi os.FileInfo, err error) error {
|
return func(file string, fi os.FileInfo, err error) error {
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import (
|
|||||||
"archive/tar"
|
"archive/tar"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -22,7 +20,6 @@ import (
|
|||||||
"github.com/go-git/go-git/v5/plumbing/format/index"
|
"github.com/go-git/go-git/v5/plumbing/format/index"
|
||||||
"github.com/go-git/go-git/v5/storage/filesystem"
|
"github.com/go-git/go-git/v5/storage/filesystem"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type memoryFs struct {
|
type memoryFs struct {
|
||||||
@@ -177,47 +174,3 @@ func TestSymlinks(t *testing.T) {
|
|||||||
assert.Equal(t, ".env", files["test.env"].Linkname)
|
assert.Equal(t, ".env", files["test.env"].Linkname)
|
||||||
assert.ErrorIs(t, err, io.EOF, "tar must be read cleanly to EOF")
|
assert.ErrorIs(t, err, io.EOF, "tar must be read cleanly to EOF")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression for https://gitea.com/gitea/runner/issues/876 and /941:
|
|
||||||
// re-copying an action directory must overwrite a pre-existing read-only
|
|
||||||
// file (e.g. a git pack .idx at mode 0444) instead of failing with EACCES
|
|
||||||
// on macOS or "Access is denied" on Windows.
|
|
||||||
func TestCopyCollectorWriteFileOverwritesReadOnlyFile(t *testing.T) {
|
|
||||||
dst := t.TempDir()
|
|
||||||
target := filepath.Join(dst, "sub", "pack.idx")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(target, []byte("old"), 0o444))
|
|
||||||
|
|
||||||
src := filepath.Join(t.TempDir(), "pack.idx")
|
|
||||||
require.NoError(t, os.WriteFile(src, []byte("new"), 0o444))
|
|
||||||
fi, err := os.Stat(src)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cc := &CopyCollector{DstDir: dst}
|
|
||||||
require.NoError(t, cc.WriteFile("sub/pack.idx", fi, "", strings.NewReader("new")))
|
|
||||||
|
|
||||||
got, err := os.ReadFile(target)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "new", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without the destination removal, os.Symlink fails with EEXIST when the
|
|
||||||
// path already holds a regular file from an earlier copy of the action.
|
|
||||||
func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("creating symlinks requires elevated privileges on Windows")
|
|
||||||
}
|
|
||||||
dst := t.TempDir()
|
|
||||||
target := filepath.Join(dst, "link")
|
|
||||||
require.NoError(t, os.WriteFile(target, []byte("stale"), 0o644))
|
|
||||||
|
|
||||||
fi, err := os.Lstat(target)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cc := &CopyCollector{DstDir: dst}
|
|
||||||
require.NoError(t, cc.WriteFile("link", fi, "target", nil))
|
|
||||||
|
|
||||||
resolved, err := os.Readlink(target)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "target", resolved)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,18 @@
|
|||||||
|
|
||||||
package lookpath
|
package lookpath
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
type Env interface {
|
type Env interface {
|
||||||
Getenv(name string) string
|
Getenv(name string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type defaultEnv struct{}
|
||||||
|
|
||||||
|
func (*defaultEnv) Getenv(name string) string {
|
||||||
|
return os.Getenv(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LookPath(file string) (string, error) {
|
||||||
|
return LookPath2(file, &defaultEnv{})
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ type WorkflowFiles struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
|
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
|
||||||
|
//
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
|
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
|
||||||
path, err := filepath.Abs(path)
|
path, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ func TestWorkflow(t *testing.T) {
|
|||||||
|
|
||||||
// Check that an invalid job id returns error
|
// Check that an invalid job id returns error
|
||||||
result, err := createStages(&workflow, "invalid_job_id")
|
result, err := createStages(&workflow, "invalid_job_id")
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NotNil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Nil(t, result)
|
assert.Nil(t, result)
|
||||||
|
|
||||||
// Check that an valid job id returns non-error
|
// Check that an valid job id returns non-error
|
||||||
result, err = createStages(&workflow, "valid_job")
|
result, err = createStages(&workflow, "valid_job")
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.NotNil(t, result)
|
assert.NotNil(t, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ jobs:
|
|||||||
with-volumes:
|
with-volumes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: node:24-bookworm-slim
|
image: node:16-buster-slim
|
||||||
volumes:
|
volumes:
|
||||||
- my_docker_volume:/path/to/volume
|
- my_docker_volume:/path/to/volume
|
||||||
- /path/to/nonexist/directory
|
- /path/to/nonexist/directory
|
||||||
|
|||||||
@@ -325,20 +325,14 @@ func (j *Job) Needs() []string {
|
|||||||
|
|
||||||
// RunsOn list for Job
|
// RunsOn list for Job
|
||||||
func (j *Job) RunsOn() []string {
|
func (j *Job) RunsOn() []string {
|
||||||
return RunsOnFromNode(j.RawRunsOn)
|
switch j.RawRunsOn.Kind {
|
||||||
}
|
|
||||||
|
|
||||||
// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a
|
|
||||||
// copy of the node (avoiding mutation of the shared Job) before reading the labels.
|
|
||||||
func RunsOnFromNode(rawRunsOn yaml.Node) []string {
|
|
||||||
switch rawRunsOn.Kind {
|
|
||||||
case yaml.MappingNode:
|
case yaml.MappingNode:
|
||||||
var val struct {
|
var val struct {
|
||||||
Group string
|
Group string
|
||||||
Labels yaml.Node
|
Labels yaml.Node
|
||||||
}
|
}
|
||||||
|
|
||||||
if !decodeNode(rawRunsOn, &val) {
|
if !decodeNode(j.RawRunsOn, &val) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +344,7 @@ func RunsOnFromNode(rawRunsOn yaml.Node) []string {
|
|||||||
|
|
||||||
return labels
|
return labels
|
||||||
default:
|
default:
|
||||||
return nodeAsStringSlice(rawRunsOn)
|
return nodeAsStringSlice(j.RawRunsOn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +440,8 @@ func (j *Job) Matrix() map[string][]any {
|
|||||||
|
|
||||||
// GetMatrixes returns the matrix cross product
|
// GetMatrixes returns the matrix cross product
|
||||||
// It skips includes and hard fails excludes for non-existing keys
|
// It skips includes and hard fails excludes for non-existing keys
|
||||||
|
//
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func (j *Job) GetMatrixes() ([]map[string]any, error) {
|
func (j *Job) GetMatrixes() ([]map[string]any, error) {
|
||||||
matrixes := make([]map[string]any, 0)
|
matrixes := make([]map[string]any, 0)
|
||||||
if j.Strategy != nil {
|
if j.Strategy != nil {
|
||||||
@@ -651,33 +647,6 @@ type Step struct {
|
|||||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across
|
|
||||||
// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env
|
|
||||||
// yaml.Nodes in place, so each job must own its copy.
|
|
||||||
func (s *Step) Clone() *Step {
|
|
||||||
clone := *s
|
|
||||||
clone.If = CloneYamlNode(s.If)
|
|
||||||
clone.Env = CloneYamlNode(s.Env)
|
|
||||||
clone.With = maps.Clone(s.With)
|
|
||||||
return &clone
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without
|
|
||||||
// mutating a node shared across parallel jobs.
|
|
||||||
func CloneYamlNode(n yaml.Node) yaml.Node {
|
|
||||||
clone := n
|
|
||||||
if n.Content != nil {
|
|
||||||
clone.Content = make([]*yaml.Node, len(n.Content))
|
|
||||||
for i, child := range n.Content {
|
|
||||||
if child != nil {
|
|
||||||
childClone := CloneYamlNode(*child)
|
|
||||||
clone.Content[i] = &childClone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
// String gets the name of step
|
// String gets the name of step
|
||||||
func (s *Step) String() string {
|
func (s *Step) String() string {
|
||||||
if s.Name != "" {
|
if s.Name != "" {
|
||||||
|
|||||||
@@ -9,29 +9,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestStepCloneIsolatesMutableFields guards the parallel-matrix race fix: combinations share the
|
|
||||||
// job's *Step, and Clone() must hand each a copy whose If/Env nodes and With map can be mutated
|
|
||||||
// independently. A shallow copy would share Env.Content's backing array (and the With map) and
|
|
||||||
// leak writes across combinations.
|
|
||||||
func TestStepCloneIsolatesMutableFields(t *testing.T) {
|
|
||||||
var orig Step
|
|
||||||
require.NoError(t, yaml.Unmarshal([]byte("if: ${{ env.X == 'a' }}\nenv:\n KEY: original\nwith:\n arg: original\n"), &orig))
|
|
||||||
require.Len(t, orig.Env.Content, 2) // [key, value]
|
|
||||||
|
|
||||||
clone := orig.Clone()
|
|
||||||
clone.If.Value = "changed"
|
|
||||||
clone.Env.Content[1].Value = "changed"
|
|
||||||
clone.With["arg"] = "changed"
|
|
||||||
|
|
||||||
assert.Equal(t, "${{ env.X == 'a' }}", orig.If.Value, "If must not be shared with the clone")
|
|
||||||
assert.Equal(t, "original", orig.Env.Content[1].Value, "Env nodes must not be shared with the clone")
|
|
||||||
assert.Equal(t, "original", orig.With["arg"], "With map must not be shared with the clone")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
||||||
yaml := `
|
yaml := `
|
||||||
name: local-action-docker-url
|
name: local-action-docker-url
|
||||||
@@ -76,7 +56,7 @@ jobs:
|
|||||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
newSchedules = workflow.OnSchedule()
|
newSchedules = workflow.OnSchedule()
|
||||||
assert.Empty(t, newSchedules)
|
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
yaml = `
|
yaml = `
|
||||||
name: local-action-docker-url
|
name: local-action-docker-url
|
||||||
@@ -94,7 +74,7 @@ jobs:
|
|||||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
newSchedules = workflow.OnSchedule()
|
newSchedules = workflow.OnSchedule()
|
||||||
assert.Empty(t, newSchedules)
|
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
yaml = `
|
yaml = `
|
||||||
name: local-action-docker-url
|
name: local-action-docker-url
|
||||||
@@ -111,7 +91,7 @@ jobs:
|
|||||||
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
newSchedules = workflow.OnSchedule()
|
newSchedules = workflow.OnSchedule()
|
||||||
assert.Empty(t, newSchedules)
|
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadWorkflow_StringEvent(t *testing.T) {
|
func TestReadWorkflow_StringEvent(t *testing.T) {
|
||||||
@@ -890,7 +870,7 @@ jobs:
|
|||||||
assert.Nil(t, matrix, "matrix should be nil for jobs without strategy")
|
assert.Nil(t, matrix, "matrix should be nil for jobs without strategy")
|
||||||
} else {
|
} else {
|
||||||
assert.NotNil(t, matrix, "matrix should not be nil")
|
assert.NotNil(t, matrix, "matrix should not be nil")
|
||||||
assert.Len(t, matrix, tt.wantLen, "matrix should have expected number of keys")
|
assert.Equal(t, tt.wantLen, len(matrix), "matrix should have expected number of keys") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
if tt.checkFn != nil {
|
if tt.checkFn != nil {
|
||||||
tt.checkFn(t, matrix)
|
tt.checkFn(t, matrix)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
@@ -45,11 +44,6 @@ type runAction func(step actionStep, actionDir string, remoteAction *remoteActio
|
|||||||
//go:embed res/trampoline.js
|
//go:embed res/trampoline.js
|
||||||
var trampoline embed.FS
|
var trampoline embed.FS
|
||||||
|
|
||||||
var (
|
|
||||||
ContainerImageExistsLocally = container.ImageExistsLocally
|
|
||||||
ContainerNewDockerBuildExecutor = container.NewDockerBuildExecutor
|
|
||||||
)
|
|
||||||
|
|
||||||
func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) {
|
func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
allErrors := []error{}
|
allErrors := []error{}
|
||||||
@@ -154,8 +148,6 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
|||||||
return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta)
|
return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer git.AcquireCloneLock(actionDir)()
|
|
||||||
|
|
||||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -205,7 +197,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
if remoteAction == nil {
|
if remoteAction == nil {
|
||||||
location = containerActionDir
|
location = containerActionDir
|
||||||
}
|
}
|
||||||
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil)
|
return execAsDocker(ctx, step, actionName, location, remoteAction == nil)
|
||||||
case x.IsComposite():
|
case x.IsComposite():
|
||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -273,7 +265,9 @@ func removeGitIgnore(ctx context.Context, directory string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: break out parts of function to reduce complexicity
|
// TODO: break out parts of function to reduce complexicity
|
||||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
|
//
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
|
func execAsDocker(ctx context.Context, step actionStep, actionName, basedir string, localAction bool) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
@@ -292,12 +286,12 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
image = strings.ToLower(image)
|
image = strings.ToLower(image)
|
||||||
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
||||||
|
|
||||||
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
anyArchExists, err := container.ImageExistsLocally(ctx, image, "any")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
correctArchExists, err := ContainerImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture)
|
correctArchExists, err := container.ImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -329,21 +323,13 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
}
|
}
|
||||||
defer buildContext.Close()
|
defer buildContext.Close()
|
||||||
}
|
}
|
||||||
prepImage = ContainerNewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
|
prepImage = container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
|
||||||
ContextDir: contextDir,
|
ContextDir: contextDir,
|
||||||
Dockerfile: fileName,
|
Dockerfile: fileName,
|
||||||
ImageTag: image,
|
ImageTag: image,
|
||||||
BuildContext: buildContext,
|
BuildContext: buildContext,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
})
|
})
|
||||||
if buildContext == nil {
|
|
||||||
// Held across the whole build: the daemon drains contextDir lazily.
|
|
||||||
inner := prepImage
|
|
||||||
prepImage = func(ctx context.Context) error {
|
|
||||||
defer git.AcquireCloneLock(actionDir)()
|
|
||||||
return inner(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -443,7 +429,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
Image: image,
|
Image: image,
|
||||||
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
||||||
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
||||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
|
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
|
||||||
Env: envList,
|
Env: envList,
|
||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
NetworkMode: networkMode,
|
NetworkMode: networkMode,
|
||||||
@@ -455,8 +441,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
Options: rc.Config.ContainerOptions,
|
Options: rc.Config.ContainerOptions,
|
||||||
AutoRemove: rc.Config.AutoRemove,
|
AutoRemove: rc.Config.AutoRemove,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.Config.ValidVolumes,
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
|
||||||
})
|
})
|
||||||
return stepContainer
|
return stepContainer
|
||||||
}
|
}
|
||||||
|
|||||||
45
act/runner/action_cache_offline_mode.go
Normal file
45
act/runner/action_cache_offline_mode.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GoGitActionCacheOfflineMode struct {
|
||||||
|
Parent GoGitActionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c GoGitActionCacheOfflineMode) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
|
||||||
|
sha, fetchErr := c.Parent.Fetch(ctx, cacheDir, url, ref, token)
|
||||||
|
gitPath := path.Join(c.Parent.Path, safeFilename(cacheDir)+".git")
|
||||||
|
gogitrepo, err := git.PlainOpen(gitPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fetchErr
|
||||||
|
}
|
||||||
|
refName := plumbing.ReferenceName("refs/action-cache-offline/" + ref)
|
||||||
|
r, err := gogitrepo.Reference(refName, true)
|
||||||
|
if fetchErr == nil {
|
||||||
|
if err != nil || sha != r.Hash().String() {
|
||||||
|
if err == nil {
|
||||||
|
refName = r.Name()
|
||||||
|
}
|
||||||
|
ref := plumbing.NewHashReference(refName, plumbing.NewHash(sha))
|
||||||
|
_ = gogitrepo.Storer.SetReference(ref)
|
||||||
|
}
|
||||||
|
} else if err == nil {
|
||||||
|
return r.Hash().String(), nil
|
||||||
|
}
|
||||||
|
return sha, fetchErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c GoGitActionCacheOfflineMode) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
|
||||||
|
return c.Parent.GetTarArchive(ctx, cacheDir, sha, includePrefix)
|
||||||
|
}
|
||||||
@@ -8,139 +8,64 @@ import (
|
|||||||
"archive/tar"
|
"archive/tar"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func runGit(t *testing.T, dir string, args ...string) {
|
|
||||||
t.Helper()
|
|
||||||
if dir != "" {
|
|
||||||
args = append([]string{"-C", dir}, args...)
|
|
||||||
}
|
|
||||||
cmd := exec.Command("git", args...)
|
|
||||||
// Fixed identity and host-config isolation so commits succeed offline regardless of the
|
|
||||||
// host's git config (mirrors gitCmd in act/common/git).
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
|
|
||||||
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
|
|
||||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
|
||||||
)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShortShaActionRejected verifies a `uses` ref that is a shortened commit SHA is rejected
|
|
||||||
// with a clear error. The action is resolved from a local repo (via DefaultActionInstance) so
|
|
||||||
// this runs offline.
|
|
||||||
func TestShortShaActionRejected(t *testing.T) {
|
|
||||||
// a local "remote" action repo at <root>/actions/hello-world-docker-action
|
|
||||||
actionRoot := t.TempDir()
|
|
||||||
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
|
|
||||||
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
||||||
runGit(t, "", "init", "--initial-branch=main", repo)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
|
|
||||||
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
|
||||||
runGit(t, repo, "add", ".")
|
|
||||||
runGit(t, repo, "commit", "-m", "initial")
|
|
||||||
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
|
|
||||||
require.NoError(t, err)
|
|
||||||
shortSha := strings.TrimSpace(string(out))[:7]
|
|
||||||
|
|
||||||
// a workflow that uses the action at the short SHA
|
|
||||||
wfDir := filepath.Join(t.TempDir(), "wf")
|
|
||||||
require.NoError(t, os.MkdirAll(wfDir, 0o755))
|
|
||||||
wf := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", shortSha)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(wfDir, "push.yml"), []byte(wf), 0o644))
|
|
||||||
|
|
||||||
runner, err := New(&Config{
|
|
||||||
Workdir: wfDir,
|
|
||||||
EventName: "push",
|
|
||||||
Platforms: map[string]string{"ubuntu-latest": baseImage},
|
|
||||||
GitHubInstance: "github.com",
|
|
||||||
DefaultActionInstance: actionRoot,
|
|
||||||
ContainerMaxLifetime: time.Hour,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
planner, err := model.NewWorkflowPlanner(wfDir, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
plan, err := planner.PlanEvent("push")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = runner.NewPlanExecutor(plan)(common.WithDryrun(context.Background(), true))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "shortened version of a commit SHA")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestActionCache(t *testing.T) {
|
func TestActionCache(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
a := assert.New(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{
|
cache := &GoGitActionCache{
|
||||||
Path: t.TempDir(),
|
Path: t.TempDir(),
|
||||||
}
|
}
|
||||||
cacheDir := "local/act-test-actions"
|
ctx := context.Background()
|
||||||
|
cacheDir := "nektos/act-test-actions"
|
||||||
|
repo := "https://github.com/nektos/act-test-actions"
|
||||||
refs := []struct {
|
refs := []struct {
|
||||||
Name string
|
Name string
|
||||||
Ref string
|
CacheDir string
|
||||||
|
Repo string
|
||||||
|
Ref string
|
||||||
}{
|
}{
|
||||||
{Name: "Fetch Branch Name", Ref: "main"},
|
{
|
||||||
{Name: "Fetch Branch Name Absolutely", Ref: "refs/heads/main"},
|
Name: "Fetch Branch Name",
|
||||||
{Name: "Fetch HEAD", Ref: "HEAD"},
|
CacheDir: cacheDir,
|
||||||
{Name: "Fetch Sha", Ref: fullSha},
|
Repo: repo,
|
||||||
|
Ref: "main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Fetch Branch Name Absolutely",
|
||||||
|
CacheDir: cacheDir,
|
||||||
|
Repo: repo,
|
||||||
|
Ref: "refs/heads/main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Fetch HEAD",
|
||||||
|
CacheDir: cacheDir,
|
||||||
|
Repo: repo,
|
||||||
|
Ref: "HEAD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Fetch Sha",
|
||||||
|
CacheDir: cacheDir,
|
||||||
|
Repo: repo,
|
||||||
|
Ref: "de984ca37e4df4cb9fd9256435a3b82c4a2662b1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, c := range refs {
|
for _, c := range refs {
|
||||||
t.Run(c.Name, func(t *testing.T) {
|
t.Run(c.Name, func(t *testing.T) {
|
||||||
sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "")
|
sha, err := cache.Fetch(ctx, c.CacheDir, c.Repo, c.Ref, "")
|
||||||
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
|
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
atar, err := cache.GetTarArchive(ctx, cacheDir, sha, "js")
|
atar, err := cache.GetTarArchive(ctx, c.CacheDir, sha, "js")
|
||||||
// NotNil, not NotEmpty: atar is a live io.PipeReader whose producer goroutine is
|
if !a.NoError(err) || !a.NotEmpty(atar) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
// 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
|
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)
|
mytar := tar.NewReader(atar)
|
||||||
th, err := mytar.Next()
|
th, err := mytar.Next()
|
||||||
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act
|
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|||||||
@@ -9,13 +9,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -142,7 +137,7 @@ runs:
|
|||||||
|
|
||||||
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.Nil(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)
|
closerMock.AssertExpectations(t)
|
||||||
@@ -252,158 +247,8 @@ func TestActionRunner(t *testing.T) {
|
|||||||
|
|
||||||
err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx)
|
err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx)
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
actionDir := t.TempDir()
|
|
||||||
|
|
||||||
releaseCopy := make(chan struct{})
|
|
||||||
release := sync.OnceFunc(func() { close(releaseCopy) })
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
copyEntered := make(chan struct{})
|
|
||||||
|
|
||||||
cm := &containerMock{}
|
|
||||||
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error {
|
|
||||||
close(copyEntered)
|
|
||||||
<-releaseCopy
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
step := &stepActionRemote{
|
|
||||||
Step: &model.Step{Uses: "remote/action@v1"},
|
|
||||||
RunContext: &RunContext{
|
|
||||||
Config: &Config{},
|
|
||||||
JobContainer: cm,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
copyDone := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
copyDone <- maybeCopyToActionDir(ctx, step, actionDir, "", "/var/run/act/actions/")
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-copyEntered:
|
|
||||||
case err := <-copyDone:
|
|
||||||
t.Fatalf("maybeCopyToActionDir returned before CopyDir was entered: %v", err)
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("CopyDir was not entered within 1 second")
|
|
||||||
}
|
|
||||||
|
|
||||||
peerAcquired := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
unlock := git.AcquireCloneLock(actionDir)
|
|
||||||
close(peerAcquired)
|
|
||||||
unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-peerAcquired:
|
|
||||||
t.Fatal("peer AcquireCloneLock returned while CopyDir was running")
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
}
|
|
||||||
|
|
||||||
release()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-copyDone:
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("maybeCopyToActionDir returned error: %v", err)
|
|
||||||
}
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("maybeCopyToActionDir did not return after CopyDir was unblocked")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-peerAcquired:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("peer AcquireCloneLock did not proceed after lock released")
|
|
||||||
}
|
|
||||||
|
|
||||||
cm.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
|
||||||
actionDir := t.TempDir()
|
|
||||||
|
|
||||||
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(actionDir))
|
|
||||||
defer unlockOnce()
|
|
||||||
|
|
||||||
innerEntered := make(chan struct{})
|
|
||||||
releaseInner := make(chan struct{})
|
|
||||||
releaseOnce := sync.OnceFunc(func() { close(releaseInner) })
|
|
||||||
defer releaseOnce()
|
|
||||||
|
|
||||||
origImageExists := ContainerImageExistsLocally
|
|
||||||
ContainerImageExistsLocally = func(_ context.Context, _, _ string) (bool, error) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
defer func() { ContainerImageExistsLocally = origImageExists }()
|
|
||||||
|
|
||||||
origBuildExec := ContainerNewDockerBuildExecutor
|
|
||||||
ContainerNewDockerBuildExecutor = func(_ container.NewDockerBuildExecutorInput) common.Executor {
|
|
||||||
return func(_ context.Context) error {
|
|
||||||
close(innerEntered)
|
|
||||||
<-releaseInner
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer func() { ContainerNewDockerBuildExecutor = origBuildExec }()
|
|
||||||
|
|
||||||
step := &stepActionRemote{
|
|
||||||
Step: &model.Step{ID: "1", Uses: "remote/action@v1", With: map[string]string{}},
|
|
||||||
RunContext: &RunContext{
|
|
||||||
Config: &Config{},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "1",
|
|
||||||
Workflow: &model.Workflow{
|
|
||||||
Name: "wf",
|
|
||||||
Jobs: map[string]*model.Job{"1": {}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
JobContainer: &containerMock{},
|
|
||||||
},
|
|
||||||
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
|
|
||||||
env: map[string]string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false) }()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-innerEntered:
|
|
||||||
t.Fatal("inner build executor ran before clone lock was released")
|
|
||||||
case err := <-done:
|
|
||||||
t.Fatalf("execAsDocker returned before inner was entered: %v", err)
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockOnce()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-innerEntered:
|
|
||||||
case err := <-done:
|
|
||||||
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()
|
|
||||||
releaseOnce()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
arg = UnescapeCommandData(arg)
|
arg = unescapeCommandData(arg)
|
||||||
kvPairs = unescapeKvPairs(kvPairs)
|
kvPairs = unescapeKvPairs(kvPairs)
|
||||||
switch command {
|
switch command {
|
||||||
case "set-env":
|
case "set-env":
|
||||||
@@ -120,7 +120,7 @@ func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string,
|
|||||||
|
|
||||||
result, ok := rc.StepResults[stepID]
|
result, ok := rc.StepResults[stepID]
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Infof("No outputs registered for step '%s'", stepID)
|
logger.Infof(" \U00002757 no outputs used step '%s'", stepID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
|
|||||||
return rtn
|
return rtn
|
||||||
}
|
}
|
||||||
|
|
||||||
func UnescapeCommandData(arg string) string {
|
func unescapeCommandData(arg string) string {
|
||||||
escapeMap := map[string]string{
|
escapeMap := map[string]string{
|
||||||
"%25": "%",
|
"%25": "%",
|
||||||
"%0D": "\r",
|
"%0D": "\r",
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ func escapeFormatString(in string) string {
|
|||||||
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||||
return in, nil
|
return in, nil
|
||||||
@@ -471,6 +472,7 @@ func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (str
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
|
||||||
inputs := map[string]any{}
|
inputs := map[string]any{}
|
||||||
|
|
||||||
@@ -562,15 +564,15 @@ func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
|
|||||||
secrets = rc.caller.runContext.Config.Secrets
|
secrets = rc.caller.runContext.Config.Secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
|
if secrets == nil {
|
||||||
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
|
secrets = map[string]string{}
|
||||||
// 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
|
for k, v := range secrets {
|
||||||
|
secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc.Config.Secrets
|
return rc.Config.Secrets
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
|
|
||||||
mobyclient "github.com/moby/moby/client"
|
|
||||||
)
|
|
||||||
|
|
||||||
// requireLinuxDocker skips on non-Linux hosts. Some integration workflows need Docker features
|
|
||||||
// that only a Linux daemon provides (host networking, host /proc bind mounts); Docker Desktop
|
|
||||||
// on macOS/Windows does not, so those tests can only run on Linux.
|
|
||||||
func requireLinuxDocker(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
if runtime.GOOS != "linux" {
|
|
||||||
t.Skip("skipping: requires a Linux Docker host")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireDocker skips the test unless a reachable docker daemon is available.
|
|
||||||
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
|
||||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
|
||||||
func requireDocker(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
|
||||||
cli, err := container.GetDockerClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
|
||||||
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour
|
|
||||||
// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref);
|
|
||||||
// gating lets the rest of the suite run offline without these failing.
|
|
||||||
func requireNetwork(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("skipping: network unavailable: %v", err)
|
|
||||||
}
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
|
||||||
// self-hosted (host environment) suite, which runs steps directly on the host.
|
|
||||||
func requireHostTools(t *testing.T, tools ...string) {
|
|
||||||
t.Helper()
|
|
||||||
for _, tool := range tools {
|
|
||||||
if _, err := exec.LookPath(tool); err != nil {
|
|
||||||
t.Skipf("skipping: required host tool %q not found: %v", tool, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,13 +24,7 @@ type jobInfo interface {
|
|||||||
result(result string)
|
result(result string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportStepError emits the GitHub Actions ##[error] annotation and records
|
//nolint:contextcheck,gocyclo // composes many step executors
|
||||||
// the error against the job so the job is reported as failed.
|
|
||||||
func reportStepError(ctx context.Context, err error) {
|
|
||||||
common.Logger(ctx).Errorf("##[error]%v", err)
|
|
||||||
common.SetJobError(ctx, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
||||||
steps := make([]common.Executor, 0)
|
steps := make([]common.Executor, 0)
|
||||||
preSteps := make([]common.Executor, 0)
|
preSteps := make([]common.Executor, 0)
|
||||||
@@ -39,7 +33,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
steps = append(steps, func(ctx context.Context) error {
|
steps = append(steps, func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
if len(info.matrix()) > 0 {
|
if len(info.matrix()) > 0 {
|
||||||
logger.Infof("Matrix: %v", info.matrix())
|
logger.Infof("\U0001F9EA Matrix: %v", info.matrix())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -82,36 +76,33 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
preExec := step.pre()
|
preExec := step.pre()
|
||||||
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
preErr := preExec(ctx)
|
preErr := preExec(ctx)
|
||||||
if preErr != nil {
|
if preErr != nil {
|
||||||
reportStepError(ctx, preErr)
|
logger.Errorf("%v", preErr)
|
||||||
|
common.SetJobError(ctx, preErr)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
reportStepError(ctx, ctx.Err())
|
logger.Errorf("%v", ctx.Err())
|
||||||
|
common.SetJobError(ctx, ctx.Err())
|
||||||
}
|
}
|
||||||
return preErr
|
return preErr
|
||||||
}))
|
}))
|
||||||
|
|
||||||
stepExec := step.main()
|
stepExec := step.main()
|
||||||
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
|
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
err := stepExec(ctx)
|
err := stepExec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reportStepError(ctx, err)
|
logger.Errorf("%v", err)
|
||||||
|
common.SetJobError(ctx, err)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
reportStepError(ctx, ctx.Err())
|
logger.Errorf("%v", ctx.Err())
|
||||||
|
common.SetJobError(ctx, ctx.Err())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}))
|
}))
|
||||||
|
|
||||||
postFn := step.post()
|
postExec := useStepLogger(rc, stepModel, stepStagePost, step.post())
|
||||||
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
|
|
||||||
err := postFn(ctx)
|
|
||||||
if err != nil {
|
|
||||||
reportStepError(ctx, err)
|
|
||||||
} else if ctx.Err() != nil {
|
|
||||||
reportStepError(ctx, ctx.Err())
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if postExecutor != nil {
|
if postExecutor != nil {
|
||||||
// run the post executor in reverse order
|
// run the post executor in reverse order
|
||||||
postExecutor = postExec.Finally(postExecutor)
|
postExecutor = postExec.Finally(postExecutor)
|
||||||
@@ -166,7 +157,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
pipeline = append(pipeline, steps...)
|
pipeline = append(pipeline, steps...)
|
||||||
|
|
||||||
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
|
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
|
||||||
Finally(func(ctx context.Context) error {
|
Finally(func(ctx context.Context) error { //nolint:contextcheck // intentionally detaches from canceled parent
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
if ctx.Err() == context.Canceled {
|
if ctx.Err() == context.Canceled {
|
||||||
// in case of an aborted run, we still should execute the
|
// in case of an aborted run, we still should execute the
|
||||||
@@ -183,25 +174,18 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
// Matrix combinations share one *model.Job and run in parallel; serialize the
|
jobResult := "success"
|
||||||
// read-modify-write of the job result so a failing combination is not lost-updated by a
|
// we have only one result for a whole matrix build, so we need
|
||||||
// concurrent succeeding one.
|
// to keep an existing result state if we run a matrix
|
||||||
job := rc.Run.Job()
|
if len(info.matrix()) > 0 && rc.Run.Job().Result != "" {
|
||||||
jobResult := func() string {
|
jobResult = rc.Run.Job().Result
|
||||||
defer lockJob(job)()
|
}
|
||||||
result := "success"
|
|
||||||
// we have only one result for a whole matrix build, so we need
|
|
||||||
// to keep an existing result state if we run a matrix
|
|
||||||
if len(info.matrix()) > 0 && job.Result != "" {
|
|
||||||
result = job.Result
|
|
||||||
}
|
|
||||||
if !success {
|
|
||||||
result = "failure"
|
|
||||||
}
|
|
||||||
info.result(result)
|
|
||||||
return result
|
|
||||||
}()
|
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
jobResult = "failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
info.result(jobResult)
|
||||||
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.JobName, jobResult) // For Gitea
|
||||||
@@ -213,7 +197,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
jobResultMessage = "failed"
|
jobResultMessage = "failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
logger.WithField("jobResult", jobResult).Infof("\U0001F3C1 Job %s", jobResultMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setJobOutputs(ctx context.Context, rc *RunContext) {
|
func setJobOutputs(ctx context.Context, rc *RunContext) {
|
||||||
@@ -227,11 +211,7 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
|
|||||||
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
rc.caller.runContext.Run.Job().Outputs = callerOutputs
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestJobExecutor(t *testing.T) {
|
func TestJobExecutor(t *testing.T) {
|
||||||
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
||||||
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
||||||
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
||||||
{workdir, "uses-github-root", "push", "", platforms, secrets},
|
{workdir, "uses-github-root", "push", "", platforms, secrets},
|
||||||
|
{workdir, "uses-github-path", "push", "", platforms, secrets},
|
||||||
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||||
|
{workdir, "uses-github-full-sha", "push", "", platforms, secrets},
|
||||||
|
{workdir, "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms, secrets},
|
||||||
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
|
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
|
||||||
}
|
}
|
||||||
// These tests are sufficient to only check syntax.
|
// These tests are sufficient to only check syntax.
|
||||||
@@ -326,7 +331,7 @@ 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
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, tt.executedSteps, executorOrder)
|
assert.Equal(t, tt.executedSteps, executorOrder)
|
||||||
|
|
||||||
jim.AssertExpectations(t)
|
jim.AssertExpectations(t)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -31,11 +30,6 @@ const (
|
|||||||
gray = 37
|
gray = 37
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
rawOutputField = "raw_output"
|
|
||||||
scriptLineCyanField = "script_line_cyan"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
colors []int
|
colors []int
|
||||||
nextColor int
|
nextColor int
|
||||||
@@ -167,29 +161,7 @@ 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
|
||||||
|
|
||||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
|
||||||
ret := oldnew
|
|
||||||
|
|
||||||
for l := range strings.SplitSeq(v, "\n") {
|
|
||||||
tm := strings.TrimSpace(l)
|
|
||||||
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
|
||||||
if len(tm) > 1 {
|
|
||||||
ret = append(ret, tm, "***")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
|
|
||||||
// raw_output (command/stream) lines; there is no bypass by field.
|
|
||||||
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
|
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
|
||||||
var oldnew []string
|
|
||||||
for _, v := range secrets {
|
|
||||||
oldnew = AppendSecretMasker(oldnew, v)
|
|
||||||
}
|
|
||||||
oldnew = slices.Clip(oldnew)
|
|
||||||
defReplacer := strings.NewReplacer(oldnew...)
|
|
||||||
return func(entry *logrus.Entry) *logrus.Entry {
|
return func(entry *logrus.Entry) *logrus.Entry {
|
||||||
if insecureSecrets {
|
if insecureSecrets {
|
||||||
return entry
|
return entry
|
||||||
@@ -197,16 +169,16 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
|
|||||||
|
|
||||||
masks := Masks(entry.Context)
|
masks := Masks(entry.Context)
|
||||||
|
|
||||||
if len(*masks) == 0 {
|
for _, v := range secrets {
|
||||||
entry.Message = defReplacer.Replace(entry.Message)
|
if v != "" {
|
||||||
} else {
|
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
|
||||||
cmasker := oldnew
|
|
||||||
|
|
||||||
for _, v := range *masks {
|
|
||||||
cmasker = AppendSecretMasker(cmasker, v)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entry.Message = strings.NewReplacer(cmasker...).Replace(entry.Message)
|
for _, v := range *masks {
|
||||||
|
if v != "" {
|
||||||
|
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
@@ -255,12 +227,8 @@ func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
|
|||||||
debugFlag = "[DEBUG] "
|
debugFlag = "[DEBUG] "
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Data[rawOutputField] == true {
|
if entry.Data["raw_output"] == true {
|
||||||
if entry.Data[scriptLineCyanField] == true {
|
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
|
||||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
|
|
||||||
}
|
|
||||||
} else if entry.Data["dryrun"] == true {
|
} else if entry.Data["dryrun"] == true {
|
||||||
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message)
|
||||||
} else {
|
} else {
|
||||||
@@ -283,7 +251,7 @@ func (f *jobLogFormatter) print(b *bytes.Buffer, entry *logrus.Entry) {
|
|||||||
debugFlag = "[DEBUG] "
|
debugFlag = "[DEBUG] "
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Data[rawOutputField] == true {
|
if entry.Data["raw_output"] == true {
|
||||||
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
||||||
} else if entry.Data["dryrun"] == true {
|
} else if entry.Data["dryrun"] == true {
|
||||||
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValueMasker(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
name string
|
|
||||||
lines string
|
|
||||||
secrets map[string]string
|
|
||||||
masks []string
|
|
||||||
disallowed []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Multiline Private Key",
|
|
||||||
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
|
|
||||||
secrets: map[string]string{
|
|
||||||
"PRIVATE_KEY": "PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END",
|
|
||||||
},
|
|
||||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiline Private Key in masks",
|
|
||||||
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
|
|
||||||
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
|
|
||||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, entry := range table {
|
|
||||||
t.Run(entry.name, func(t *testing.T) {
|
|
||||||
ctx := WithMasks(t.Context(), &entry.masks)
|
|
||||||
masker := valueMasker(false, entry.secrets)
|
|
||||||
for line := range strings.SplitSeq(entry.lines, "\n") {
|
|
||||||
lentry := masker(&logrus.Entry{
|
|
||||||
Context: ctx,
|
|
||||||
Message: line,
|
|
||||||
})
|
|
||||||
for _, line := range entry.disallowed {
|
|
||||||
assert.NotContains(t, lentry.Message, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,7 +60,7 @@ func TestMaxParallelStrategy(t *testing.T) {
|
|||||||
matrixes, err := job.GetMatrixes()
|
matrixes, err := job.GetMatrixes()
|
||||||
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.Equal(t, 5, len(matrixes)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
|
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"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"
|
||||||
@@ -28,9 +31,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
workflowDir = strings.TrimPrefix(workflowDir, "./")
|
workflowDir = strings.TrimPrefix(workflowDir, "./")
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
// resolve the local workflow against the workspace root, not the process
|
newReusableWorkflowExecutor(rc, workflowDir, fileName),
|
||||||
// working directory, so it is found regardless of where the runner is invoked
|
|
||||||
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
token := rc.Config.GetToken()
|
token := rc.Config.GetToken()
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir, token)),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -84,7 +85,7 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
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),
|
newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir, token)),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -124,37 +125,46 @@ func newActionCacheReusableWorkflowExecutor(rc *RunContext, filename string, rem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloneRemoteReusableWorkflow always invokes the clone executor — moving refs
|
var executorLock sync.Mutex
|
||||||
// (branches, tags) must be re-resolved each run, matching GitHub Actions.
|
|
||||||
//
|
func newMutexExecutor(executor common.Executor) common.Executor {
|
||||||
// Callers must not change remoteReusableWorkflow.URL, because:
|
|
||||||
// 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
|
|
||||||
//
|
|
||||||
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
|
||||||
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)
|
executorLock.Lock()
|
||||||
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
defer executorLock.Unlock()
|
||||||
URL: cloneURL,
|
|
||||||
Ref: ref,
|
return executor(ctx)
|
||||||
Dir: targetDirectory,
|
|
||||||
Token: token,
|
|
||||||
OfflineMode: rc.Config.ActionOfflineMode,
|
|
||||||
})(ctx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelNewWorkflowPlanner = model.NewWorkflowPlanner
|
func cloneIfRequired(rc *RunContext, remoteReusableWorkflow remoteReusableWorkflow, targetDirectory, token string) common.Executor {
|
||||||
|
return common.NewConditionalExecutor(
|
||||||
|
func(ctx context.Context) bool {
|
||||||
|
_, err := os.Stat(targetDirectory)
|
||||||
|
notExists := errors.Is(err, fs.ErrNotExist)
|
||||||
|
return notExists
|
||||||
|
},
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
// interpolate the cloneURL
|
||||||
|
cloneURL := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, remoteReusableWorkflow.CloneURL())
|
||||||
|
// Do not change the remoteReusableWorkflow.URL, because:
|
||||||
|
// 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
|
||||||
|
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
||||||
|
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
||||||
|
URL: cloneURL,
|
||||||
|
Ref: remoteReusableWorkflow.Ref,
|
||||||
|
Dir: targetDirectory,
|
||||||
|
Token: token,
|
||||||
|
OfflineMode: rc.Config.ActionOfflineMode,
|
||||||
|
})(ctx)
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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
|
planner, err := model.NewWorkflowPlanner(path.Join(directory, workflow), true)
|
||||||
// on the whole job run.
|
|
||||||
planner, err := func() (model.WorkflowPlanner, error) {
|
|
||||||
defer git.AcquireCloneLock(directory)()
|
|
||||||
return modelNewWorkflowPlanner(path.Join(directory, workflow), true)
|
|
||||||
}()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -287,12 +297,8 @@ 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.JobName, reusedWorkflowJobResult)
|
||||||
} else {
|
} else {
|
||||||
// Serialize this shared Job.Result write against the other matrix combos
|
|
||||||
// and setJobResult (same lockJob key).
|
|
||||||
unlock := lockJob(rc.Run.Job())
|
|
||||||
rc.result(reusedWorkflowJobResult)
|
rc.result(reusedWorkflowJobResult)
|
||||||
unlock()
|
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("\U0001F3C1 Job %s", reusedWorkflowJobResultMessage)
|
||||||
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,11 +321,6 @@ func getGitCloneToken(conf *Config, cloneURL string) string {
|
|||||||
// 1. cloneURL is from the same Gitea instance that the runner is registered to
|
// 1. cloneURL is from the same Gitea instance that the runner is registered to
|
||||||
// 2. the cloneURL does not have basic auth embedded
|
// 2. the cloneURL does not have basic auth embedded
|
||||||
func shouldCloneURLUseToken(instanceURL, cloneURL string) bool {
|
func shouldCloneURLUseToken(instanceURL, cloneURL string) bool {
|
||||||
if !strings.HasPrefix(instanceURL, "http://") &&
|
|
||||||
!strings.HasPrefix(instanceURL, "https://") {
|
|
||||||
instanceURL = "https://" + instanceURL
|
|
||||||
}
|
|
||||||
|
|
||||||
u1, err1 := url.Parse(instanceURL)
|
u1, err1 := url.Parse(instanceURL)
|
||||||
u2, err2 := url.Parse(cloneURL)
|
u2, err2 := url.Parse(cloneURL)
|
||||||
if err1 != nil || err2 != nil {
|
if err1 != nil || err2 != nil {
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Regression test for go-gitea/gitea#37483: a remote reusable workflow at a moving
|
|
||||||
// ref (branch/tag) must reflect the new tip on every invocation, not stay pinned
|
|
||||||
// to the cache populated on the first run.
|
|
||||||
func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|
||||||
if _, err := exec.LookPath("git"); err != nil {
|
|
||||||
t.Skip("git not available in PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteDir := t.TempDir()
|
|
||||||
gitMust(t, "", "init", "--bare", "--initial-branch=master", remoteDir)
|
|
||||||
|
|
||||||
workDir := t.TempDir()
|
|
||||||
gitMust(t, "", "clone", remoteDir, workDir)
|
|
||||||
gitMust(t, workDir, "config", "user.email", "test@test")
|
|
||||||
gitMust(t, workDir, "config", "user.name", "test")
|
|
||||||
gitMust(t, workDir, "checkout", "-b", "master")
|
|
||||||
|
|
||||||
const workflowPath = ".gitea/workflows/reusable.yml"
|
|
||||||
tmpl := func(tag string) string {
|
|
||||||
return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(workDir, ".gitea/workflows"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v1")), 0o644))
|
|
||||||
gitMust(t, workDir, "add", workflowPath)
|
|
||||||
gitMust(t, workDir, "commit", "-m", "v1")
|
|
||||||
gitMust(t, workDir, "push", "-u", "origin", "master")
|
|
||||||
|
|
||||||
rc := &RunContext{
|
|
||||||
Config: &Config{},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "j1",
|
|
||||||
Workflow: &model.Workflow{
|
|
||||||
Name: "wf",
|
|
||||||
Jobs: map[string]*model.Job{"j1": {}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cacheDir := t.TempDir()
|
|
||||||
|
|
||||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
|
||||||
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, tmpl("v1"), string(got))
|
|
||||||
|
|
||||||
// Branch tip moves; cache key (cacheDir) does not.
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v2")), 0o644))
|
|
||||||
gitMust(t, workDir, "commit", "-am", "v2")
|
|
||||||
gitMust(t, workDir, "push", "origin", "master")
|
|
||||||
|
|
||||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
|
||||||
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
|
||||||
workflowDir := t.TempDir()
|
|
||||||
|
|
||||||
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir))
|
|
||||||
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{
|
|
||||||
Config: &Config{},
|
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
|
|
||||||
}
|
|
||||||
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml")
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- exec(context.Background()) }()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-plannerCalled:
|
|
||||||
t.Fatal("planner ran while clone lock was held")
|
|
||||||
case err := <-done:
|
|
||||||
t.Fatalf("executor returned before planner was reached: %v", err)
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockOnce()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-plannerCalled:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("planner not called after lock was released")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.Error(t, err)
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("executor did not return after planner ran")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
|
||||||
conf := &Config{
|
|
||||||
GitHubInstance: "gitea.example.net",
|
|
||||||
Secrets: map[string]string{
|
|
||||||
"GITEA_TOKEN": "token-value",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
token := getGitCloneToken(conf, "https://gitea.example.net/actions/tools")
|
|
||||||
|
|
||||||
require.Equal(t, "token-value", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldCloneURLUseToken(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
instanceURL string
|
|
||||||
cloneURL string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "same host with schemaless instance",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "https://gitea.example.net/actions/tools",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same host with schemaless instance and port",
|
|
||||||
instanceURL: "gitea.example.net:3000",
|
|
||||||
cloneURL: "https://gitea.example.net:3000/actions/tools",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "different host",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "https://github.com/actions/tools",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "embedded basic auth",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "https://user:pass@gitea.example.net/actions/tools",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid clone URL",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "://gitea.example.net/actions/tools",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.cloneURL))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitMust(t *testing.T, dir string, args ...string) {
|
|
||||||
t.Helper()
|
|
||||||
cmd := exec.Command("git", args...)
|
|
||||||
if dir != "" {
|
|
||||||
cmd.Dir = dir
|
|
||||||
}
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, "git %v: %s", args, string(out))
|
|
||||||
}
|
|
||||||
@@ -20,9 +20,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -57,10 +55,6 @@ type RunContext struct {
|
|||||||
Masks []string
|
Masks []string
|
||||||
cleanUpJobContainer common.Executor
|
cleanUpJobContainer common.Executor
|
||||||
caller *caller // job calling this RunContext (reusable workflows)
|
caller *caller // job calling this RunContext (reusable workflows)
|
||||||
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
|
|
||||||
// captured before execution so each matrix combo interpolates from the originals rather
|
|
||||||
// than from a sibling's already-resolved values written into the shared Job.Outputs.
|
|
||||||
outputTemplate map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) AddMask(mask string) {
|
func (rc *RunContext) AddMask(mask string) {
|
||||||
@@ -107,7 +101,8 @@ func (rc *RunContext) jobContainerName() string {
|
|||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
|
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
|
||||||
}
|
}
|
||||||
return createContainerName(nameParts...) // For Gitea
|
// return createSimpleContainerName(rc.Config.ContainerNamePrefix, "WORKFLOW-"+rc.Run.Workflow.Name, "JOB-"+rc.Name)
|
||||||
|
return createSimpleContainerName(nameParts...) // For Gitea
|
||||||
}
|
}
|
||||||
|
|
||||||
// networkNameForGitea return the name of the network
|
// networkNameForGitea return the name of the network
|
||||||
@@ -136,34 +131,17 @@ func getDockerDaemonSocketMountPath(daemonPath string) string {
|
|||||||
return daemonPath
|
return daemonPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// containerDaemonSocket returns the configured Docker daemon socket, applying the default
|
|
||||||
// without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job
|
|
||||||
// must never write to it.
|
|
||||||
func (rc *RunContext) containerDaemonSocket() string {
|
|
||||||
if rc.Config.ContainerDaemonSocket == "" {
|
|
||||||
return "/var/run/docker.sock"
|
|
||||||
}
|
|
||||||
return rc.Config.ContainerDaemonSocket
|
|
||||||
}
|
|
||||||
|
|
||||||
// validVolumes returns the volumes allowed on this job's containers: the configured base
|
|
||||||
// plus the volumes the runner mounts automatically. It derives a fresh slice every call and
|
|
||||||
// never mutates the shared Config (see containerDaemonSocket).
|
|
||||||
func (rc *RunContext) validVolumes() []string {
|
|
||||||
name := rc.jobContainerName()
|
|
||||||
volumes := slices.Clone(rc.Config.ValidVolumes)
|
|
||||||
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
|
||||||
return append(volumes, "act-toolcache", name, name+"-env",
|
|
||||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
|
|
||||||
|
if rc.Config.ContainerDaemonSocket == "" {
|
||||||
|
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
|
||||||
|
}
|
||||||
|
|
||||||
binds := []string{}
|
binds := []string{}
|
||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
if rc.Config.ContainerDaemonSocket != "-" {
|
||||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,13 +180,21 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
|
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
// add some default binds and mounts to ValidVolumes
|
||||||
|
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, "act-toolcache")
|
||||||
|
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name)
|
||||||
|
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name+"-env")
|
||||||
|
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
||||||
|
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket))
|
||||||
|
|
||||||
return binds, mounts
|
return binds, mounts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) startHostEnvironment() common.Executor {
|
func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
rawLogger := logger.WithField(rawOutputField, true)
|
rawLogger := logger.WithField("raw_output", true)
|
||||||
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||||
if rc.Config.LogOutput {
|
if rc.Config.LogOutput {
|
||||||
rawLogger.Infof("%s", s)
|
rawLogger.Infof("%s", s)
|
||||||
@@ -235,17 +221,15 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
}
|
}
|
||||||
toolCache := filepath.Join(cacheDir, "tool_cache")
|
toolCache := filepath.Join(cacheDir, "tool_cache")
|
||||||
rc.JobContainer = &container.HostEnvironment{
|
rc.JobContainer = &container.HostEnvironment{
|
||||||
Path: path,
|
Path: path,
|
||||||
TmpDir: runnerTmp,
|
TmpDir: runnerTmp,
|
||||||
ToolCache: toolCache,
|
ToolCache: toolCache,
|
||||||
Workdir: rc.Config.Workdir,
|
Workdir: rc.Config.Workdir,
|
||||||
CleanWorkdir: rc.Config.CleanWorkdir,
|
ActPath: actPath,
|
||||||
ActPath: actPath,
|
|
||||||
CleanUp: func() {
|
CleanUp: func() {
|
||||||
os.RemoveAll(miscpath)
|
os.RemoveAll(miscpath)
|
||||||
},
|
},
|
||||||
StdOut: logWriter,
|
StdOut: logWriter,
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
|
||||||
}
|
}
|
||||||
rc.cleanUpJobContainer = rc.JobContainer.Remove()
|
rc.cleanUpJobContainer = rc.JobContainer.Remove()
|
||||||
for k, v := range rc.JobContainer.GetRunnerContext(ctx) {
|
for k, v := range rc.JobContainer.GetRunnerContext(ctx) {
|
||||||
@@ -276,24 +260,12 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
|
//nolint:gocyclo // function handles many cases
|
||||||
// section: emit the group header and summary, return a closer for ::endgroup::.
|
|
||||||
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
|
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
|
||||||
rawLogger.Infof("::group::Starting job container")
|
|
||||||
rawLogger.Infof("image: %s", image)
|
|
||||||
rawLogger.Infof("name: %s", name)
|
|
||||||
rawLogger.Infof("network: %s", network)
|
|
||||||
return func() {
|
|
||||||
rawLogger.Infof("::endgroup::")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RunContext) startJobContainer() common.Executor {
|
func (rc *RunContext) startJobContainer() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
image := rc.platformImage(ctx)
|
image := rc.platformImage(ctx)
|
||||||
rawLogger := logger.WithField(rawOutputField, true)
|
rawLogger := logger.WithField("raw_output", true)
|
||||||
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
|
||||||
if rc.Config.LogOutput {
|
if rc.Config.LogOutput {
|
||||||
rawLogger.Infof("%s", s)
|
rawLogger.Infof("%s", s)
|
||||||
@@ -308,6 +280,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
return fmt.Errorf("failed to handle credentials: %s", err)
|
return fmt.Errorf("failed to handle credentials: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("\U0001f680 Start image=%s", image)
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
||||||
// We need to set the container name to the environment variable.
|
// We need to set the container name to the environment variable.
|
||||||
@@ -387,7 +360,6 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
NetworkAliases: []string{serviceID},
|
NetworkAliases: []string{serviceID},
|
||||||
ExposedPorts: exposedPorts,
|
ExposedPorts: exposedPorts,
|
||||||
PortBindings: portBindings,
|
PortBindings: portBindings,
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
|
||||||
})
|
})
|
||||||
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
||||||
}
|
}
|
||||||
@@ -447,14 +419,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
Options: rc.options(ctx),
|
Options: rc.options(ctx),
|
||||||
AutoRemove: rc.Config.AutoRemove,
|
AutoRemove: rc.Config.AutoRemove,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.Config.ValidVolumes,
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
|
||||||
})
|
})
|
||||||
if rc.JobContainer == nil {
|
if rc.JobContainer == nil {
|
||||||
return errors.New("Failed to create job container")
|
return errors.New("Failed to create job container")
|
||||||
}
|
}
|
||||||
|
|
||||||
defer printStartJobContainerGroup(ctx, image, name, networkName)()
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
rc.pullServicesImages(rc.Config.ForcePull),
|
rc.pullServicesImages(rc.Config.ForcePull),
|
||||||
rc.JobContainer.Pull(rc.Config.ForcePull),
|
rc.JobContainer.Pull(rc.Config.ForcePull),
|
||||||
@@ -601,29 +571,14 @@ func (rc *RunContext) ActionCacheDir() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Interpolate outputs after a job is done
|
// Interpolate outputs after a job is done
|
||||||
// jobMutexes serializes per-job result/output aggregation across the matrix combinations that
|
|
||||||
// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the
|
|
||||||
// per-directory AcquireCloneLock pattern).
|
|
||||||
var jobMutexes sync.Map // key: *model.Job; value: *sync.Mutex
|
|
||||||
|
|
||||||
func lockJob(job *model.Job) func() {
|
|
||||||
v, _ := jobMutexes.LoadOrStore(job, &sync.Mutex{})
|
|
||||||
mu := v.(*sync.Mutex)
|
|
||||||
mu.Lock()
|
|
||||||
return mu.Unlock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RunContext) interpolateOutputs() common.Executor {
|
func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
ee := rc.NewExpressionEvaluator(ctx)
|
||||||
job := rc.Run.Job()
|
for k, v := range rc.Run.Job().Outputs {
|
||||||
// Matrix combinations share this Job and its Outputs map. Interpolate from this combo's
|
interpolated := ee.Interpolate(ctx, v)
|
||||||
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
|
if v != interpolated {
|
||||||
// with its own resolved values (last wins, as on GitHub) instead of the first combo's
|
rc.Run.Job().Outputs[k] = interpolated
|
||||||
// resolved values freezing the shared template against later combos.
|
}
|
||||||
defer lockJob(job)()
|
|
||||||
for k, v := range rc.outputTemplate {
|
|
||||||
job.Outputs[k] = ee.Interpolate(ctx, v)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -631,34 +586,10 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
|
|||||||
|
|
||||||
func (rc *RunContext) startContainer() common.Executor {
|
func (rc *RunContext) startContainer() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
var err error
|
|
||||||
if rc.IsHostEnv(ctx) {
|
if rc.IsHostEnv(ctx) {
|
||||||
err = rc.startHostEnvironment()(ctx)
|
return rc.startHostEnvironment()(ctx)
|
||||||
} else {
|
|
||||||
err = rc.startJobContainer()(ctx)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
return rc.startJobContainer()(ctx)
|
||||||
// The job executor's teardown only runs after a successful start, so a failed
|
|
||||||
// start would otherwise leak the per-job network and container.
|
|
||||||
rc.cleanupFailedStart(ctx)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
|
|
||||||
if rc.cleanUpJobContainer == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cleanCtx := ctx
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
// the start likely failed because ctx was cancelled, detach so teardown still runs
|
|
||||||
var cancel context.CancelFunc
|
|
||||||
cleanCtx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
if err := rc.cleanUpJobContainer(cleanCtx); err != nil {
|
|
||||||
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,18 +621,7 @@ func (rc *RunContext) result(result string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) steps() []*model.Step {
|
func (rc *RunContext) steps() []*model.Step {
|
||||||
// Return per-job copies of the steps. Matrix combinations run in parallel and share the
|
return rc.Run.Job().Steps
|
||||||
// workflow model, but step execution mutates per-job fields and evaluates the If/Env nodes
|
|
||||||
// in place, so the *model.Step instances must not be shared across jobs (see Step.Clone).
|
|
||||||
shared := rc.Run.Job().Steps
|
|
||||||
steps := make([]*model.Step, len(shared))
|
|
||||||
for i, step := range shared {
|
|
||||||
if step == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
steps[i] = step.Clone()
|
|
||||||
}
|
|
||||||
return steps
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Executor returns a pipeline executor for all the steps in the job
|
// Executor returns a pipeline executor for all the steps in the job
|
||||||
@@ -778,15 +698,12 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
|||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate a copy: RawRunsOn is shared across parallel matrix jobs, so interpolating it in
|
if err := rc.ExprEval.EvaluateYamlNode(ctx, &job.RawRunsOn); err != nil {
|
||||||
// place would race and leak one matrix combination's runs-on into the others.
|
|
||||||
rawRunsOn := model.CloneYamlNode(job.RawRunsOn)
|
|
||||||
if err := rc.ExprEval.EvaluateYamlNode(ctx, &rawRunsOn); err != nil {
|
|
||||||
common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
|
common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return model.RunsOnFromNode(rawRunsOn)
|
return job.RunsOn()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) platformImage(ctx context.Context) string {
|
func (rc *RunContext) platformImage(ctx context.Context) string {
|
||||||
@@ -814,7 +731,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
|||||||
jobType, jobTypeErr := job.Type()
|
jobType, jobTypeErr := job.Type()
|
||||||
|
|
||||||
if runJobErr != nil {
|
if runJobErr != nil {
|
||||||
return false, fmt.Errorf("if-expression %q evaluation failed: %s", job.If.Value, runJobErr)
|
return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", job.If.Value, runJobErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jobType == model.JobTypeInvalid {
|
if jobType == model.JobTypeInvalid {
|
||||||
@@ -837,7 +754,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
|||||||
img := rc.platformImage(ctx)
|
img := rc.platformImage(ctx)
|
||||||
if img == "" {
|
if img == "" {
|
||||||
for _, platformName := range rc.runsOnPlatformNames(ctx) {
|
for _, platformName := range rc.runsOnPlatformNames(ctx) {
|
||||||
l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
|
l.Infof("\U0001F6A7 Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -852,6 +769,7 @@ func mergeMaps(maps ...map[string]string) map[string]string {
|
|||||||
return rtnMap
|
return rtnMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deprecated: use createSimpleContainerName
|
||||||
func createContainerName(parts ...string) string {
|
func createContainerName(parts ...string) string {
|
||||||
name := strings.Join(parts, "-")
|
name := strings.Join(parts, "-")
|
||||||
pattern := regexp.MustCompile("[^a-zA-Z0-9]")
|
pattern := regexp.MustCompile("[^a-zA-Z0-9]")
|
||||||
@@ -865,6 +783,22 @@ func createContainerName(parts ...string) string {
|
|||||||
return fmt.Sprintf("%s-%x", trimmedName, hash)
|
return fmt.Sprintf("%s-%x", trimmedName, hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createSimpleContainerName(parts ...string) string {
|
||||||
|
pattern := regexp.MustCompile("[^a-zA-Z0-9-]")
|
||||||
|
name := make([]string, 0, len(parts))
|
||||||
|
for _, v := range parts {
|
||||||
|
v = pattern.ReplaceAllString(v, "-")
|
||||||
|
v = strings.Trim(v, "-")
|
||||||
|
for strings.Contains(v, "--") {
|
||||||
|
v = strings.ReplaceAll(v, "--", "-")
|
||||||
|
}
|
||||||
|
if v != "" {
|
||||||
|
name = append(name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(name, "_")
|
||||||
|
}
|
||||||
|
|
||||||
func trimToLen(s string, l int) string {
|
func trimToLen(s string, l int) string {
|
||||||
if l < 0 {
|
if l < 0 {
|
||||||
l = 0
|
l = 0
|
||||||
@@ -892,6 +826,7 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
|
|||||||
return rc.StepResults
|
return rc.StepResults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
|
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
ghc := &model.GithubContext{
|
ghc := &model.GithubContext{
|
||||||
@@ -1209,9 +1144,12 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
|
|||||||
|
|
||||||
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
||||||
|
if rc.Config.ContainerDaemonSocket == "" {
|
||||||
|
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
|
||||||
|
}
|
||||||
binds := []string{}
|
binds := []string{}
|
||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
if rc.Config.ContainerDaemonSocket != "-" {
|
||||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,13 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
require "github.com/stretchr/testify/require"
|
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,49 +278,11 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextValidVolumes(t *testing.T) {
|
|
||||||
rc := &RunContext{
|
|
||||||
Name: "job",
|
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
|
|
||||||
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}},
|
|
||||||
}
|
|
||||||
name := rc.jobContainerName()
|
|
||||||
|
|
||||||
got := rc.validVolumes()
|
|
||||||
|
|
||||||
// the configured volumes plus the four the runner mounts automatically
|
|
||||||
assert.Subset(t, got, []string{"my-vol", "/host/path", "act-toolcache", name, name + "-env", "/var/run/docker.sock"})
|
|
||||||
|
|
||||||
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
|
|
||||||
// combinations share one *Config, and the previous in-place append was a data race.
|
|
||||||
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
|
|
||||||
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
|
||||||
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
|
||||||
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
|
||||||
func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
|
|
||||||
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}}
|
|
||||||
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
|
|
||||||
r := &runnerImpl{config: &Config{}}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"})
|
|
||||||
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"})
|
|
||||||
|
|
||||||
require.NoError(t, rcA.interpolateOutputs()(ctx))
|
|
||||||
require.NoError(t, rcB.interpolateOutputs()(ctx))
|
|
||||||
|
|
||||||
// Last combo wins (matching GitHub) instead of being frozen to combo A's "a".
|
|
||||||
require.Equal(t, "b", job.Outputs["o"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetGitHubContext(t *testing.T) {
|
func TestGetGitHubContext(t *testing.T) {
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{
|
Config: &Config{
|
||||||
@@ -663,88 +622,23 @@ func TestRunContextGetEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateContainerNameBoundedForLongMatrixInput(t *testing.T) {
|
func Test_createSimpleContainerName(t *testing.T) {
|
||||||
longMatrixValue := strings.Repeat("os=ubuntu-latest-go=1.24-node=22-", 20)
|
tests := []struct {
|
||||||
name := createContainerName(
|
parts []string
|
||||||
"gitea",
|
want string
|
||||||
"WORKFLOW-super-long-workflow-name",
|
}{
|
||||||
"JOB-build-matrix-"+longMatrixValue,
|
{
|
||||||
)
|
parts: []string{"a--a", "BB正", "c-C"},
|
||||||
|
want: "a-a_BB_c-C",
|
||||||
assert.LessOrEqual(t, len(name), 128)
|
},
|
||||||
assert.LessOrEqual(t, len(name+"-env"), 255)
|
{
|
||||||
assert.LessOrEqual(t, len(name+"-network"), 255)
|
parts: []string{"a-a", "", "-"},
|
||||||
assert.LessOrEqual(t, len(name+"-job1234567890"), 255)
|
want: "a-a",
|
||||||
}
|
},
|
||||||
|
|
||||||
func TestPrintStartJobContainerGroupGolden(t *testing.T) {
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
logger := log.New()
|
|
||||||
logger.SetOutput(buf)
|
|
||||||
logger.SetLevel(log.InfoLevel)
|
|
||||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
|
||||||
entry := logger.WithFields(log.Fields{"job": "j1"})
|
|
||||||
ctx := common.WithLogger(context.Background(), entry)
|
|
||||||
|
|
||||||
printStartJobContainerGroup(ctx, "node:20", "GITEA-WORKFLOW-build-JOB-test", "gitea-runner-network")()
|
|
||||||
|
|
||||||
want := strings.Join([]string{
|
|
||||||
"[j1] | ::group::Starting job container",
|
|
||||||
"[j1] | image: node:20",
|
|
||||||
"[j1] | name: GITEA-WORKFLOW-build-JOB-test",
|
|
||||||
"[j1] | network: gitea-runner-network",
|
|
||||||
"[j1] | ::endgroup::",
|
|
||||||
"",
|
|
||||||
}, "\n")
|
|
||||||
assert.Equal(t, want, buf.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunContext_cleanupFailedStart(t *testing.T) {
|
|
||||||
type ctxKey string
|
|
||||||
const sentinel = ctxKey("sentinel")
|
|
||||||
|
|
||||||
// the fresh context is cancelled via defer on return, so capture state inside the stub
|
|
||||||
type capture struct {
|
|
||||||
calls int
|
|
||||||
err error
|
|
||||||
sentinel any
|
|
||||||
}
|
}
|
||||||
newRC := func(c *capture) *RunContext {
|
for _, tt := range tests {
|
||||||
return &RunContext{
|
t.Run(strings.Join(tt.parts, " "), func(t *testing.T) {
|
||||||
JobName: "job",
|
assert.Equalf(t, tt.want, createSimpleContainerName(tt.parts...), "createSimpleContainerName(%v)", tt.parts)
|
||||||
cleanUpJobContainer: func(ctx context.Context) error {
|
})
|
||||||
c.calls++
|
|
||||||
c.err = ctx.Err()
|
|
||||||
c.sentinel = ctx.Value(sentinel)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("runs teardown on the live context", func(t *testing.T) {
|
|
||||||
var c capture
|
|
||||||
ctx := context.WithValue(context.Background(), sentinel, "v")
|
|
||||||
|
|
||||||
newRC(&c).cleanupFailedStart(ctx)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, c.calls)
|
|
||||||
require.NoError(t, c.err)
|
|
||||||
assert.Equal(t, "v", c.sentinel)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back to a fresh context when the input is done", func(t *testing.T) {
|
|
||||||
var c capture
|
|
||||||
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
newRC(&c).cleanupFailedStart(ctx)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, c.calls)
|
|
||||||
require.NoError(t, c.err)
|
|
||||||
assert.Nil(t, c.sentinel)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
|
|
||||||
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -17,7 +16,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/docker/docker/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ 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 caching action contents
|
||||||
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
|
||||||
@@ -74,14 +73,12 @@ type Config struct {
|
|||||||
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
|
||||||
CleanWorkdir bool // remove host executor workdir on teardown
|
|
||||||
DefaultActionInstance string // the default actions web site
|
DefaultActionInstance string // the default actions web site
|
||||||
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
||||||
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
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetToken: Adapt to Gitea
|
// GetToken: Adapt to Gitea
|
||||||
@@ -93,17 +90,6 @@ func (c Config) GetToken() string {
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultActionURL returns the host used for implicit remote actions.
|
|
||||||
func (c Config) DefaultActionURL() string {
|
|
||||||
if c.DefaultActionInstance != "" {
|
|
||||||
return c.DefaultActionInstance
|
|
||||||
}
|
|
||||||
if c.GitHubInstance != "" {
|
|
||||||
return c.GitHubInstance
|
|
||||||
}
|
|
||||||
return "github.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
type caller struct {
|
type caller struct {
|
||||||
runContext *RunContext
|
runContext *RunContext
|
||||||
|
|
||||||
@@ -151,6 +137,8 @@ func (runner *runnerImpl) configure() (Runner, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewPlanExecutor ...
|
// NewPlanExecutor ...
|
||||||
|
//
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||||
maxJobNameLen := 0
|
maxJobNameLen := 0
|
||||||
|
|
||||||
@@ -251,14 +239,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
|
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Run all matrix combinations of this job, then drop its aggregation mutex: the
|
pipeline = append(pipeline, common.NewParallelExecutor(maxParallel, stageExecutor...))
|
||||||
// combos are the only users of it, so once they finish the jobMutexes entry can be
|
|
||||||
// released, keeping the map from growing unbounded over a long-lived runner.
|
|
||||||
stageParallel := common.NewParallelExecutor(maxParallel, stageExecutor...)
|
|
||||||
pipeline = append(pipeline, func(ctx context.Context) error {
|
|
||||||
defer jobMutexes.Delete(job)
|
|
||||||
return stageParallel(ctx)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For pipeline execution:
|
// For pipeline execution:
|
||||||
@@ -342,11 +323,6 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
|
|||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
|
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
|
||||||
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
|
|
||||||
// rewrites the shared Job.Outputs (see interpolateOutputs).
|
|
||||||
if job := run.Job(); job != nil {
|
|
||||||
rc.outputTemplate = maps.Clone(job.Outputs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rc
|
return rc
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
@@ -27,7 +26,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
baseImage = "node:24-bookworm-slim"
|
baseImage = "node:16-buster-slim"
|
||||||
platforms map[string]string
|
platforms map[string]string
|
||||||
logLevel = log.DebugLevel
|
logLevel = log.DebugLevel
|
||||||
workdir = "testdata"
|
workdir = "testdata"
|
||||||
@@ -88,7 +87,7 @@ func TestGraphMissingEvent(t *testing.T) {
|
|||||||
plan, err := planner.PlanEvent("push")
|
plan, err := planner.PlanEvent("push")
|
||||||
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.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Contains(t, buf.String(), "no events found for workflow: no-event.yml")
|
assert.Contains(t, buf.String(), "no events found for workflow: no-event.yml")
|
||||||
log.SetOutput(out)
|
log.SetOutput(out)
|
||||||
@@ -101,7 +100,7 @@ func TestGraphMissingFirst(t *testing.T) {
|
|||||||
plan, err := planner.PlanEvent("push")
|
plan, err := planner.PlanEvent("push")
|
||||||
assert.EqualError(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.EqualError(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.NotNil(t, plan)
|
assert.NotNil(t, plan)
|
||||||
assert.Empty(t, plan.Stages)
|
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGraphWithMissing(t *testing.T) {
|
func TestGraphWithMissing(t *testing.T) {
|
||||||
@@ -115,7 +114,7 @@ func TestGraphWithMissing(t *testing.T) {
|
|||||||
|
|
||||||
plan, err := planner.PlanEvent("push")
|
plan, err := planner.PlanEvent("push")
|
||||||
assert.NotNil(t, plan)
|
assert.NotNil(t, plan)
|
||||||
assert.Empty(t, plan.Stages)
|
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.EqualError(t, err, "unable to build dependency graph for missing (missing.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.EqualError(t, err, "unable to build dependency graph for missing (missing.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
||||||
log.SetOutput(out)
|
log.SetOutput(out)
|
||||||
@@ -135,7 +134,7 @@ func TestGraphWithSomeMissing(t *testing.T) {
|
|||||||
plan, err := planner.PlanAll()
|
plan, err := planner.PlanAll()
|
||||||
assert.Error(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Error(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.NotNil(t, plan)
|
assert.NotNil(t, plan)
|
||||||
assert.Len(t, plan.Stages, 1)
|
assert.Equal(t, 1, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
||||||
assert.Contains(t, buf.String(), "unable to build dependency graph for no first (no-first.yml)")
|
assert.Contains(t, buf.String(), "unable to build dependency graph for no first (no-first.yml)")
|
||||||
log.SetOutput(out)
|
log.SetOutput(out)
|
||||||
@@ -160,7 +159,7 @@ func TestGraphEvent(t *testing.T) {
|
|||||||
plan, err = planner.PlanEvent("release")
|
plan, err = planner.PlanEvent("release")
|
||||||
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.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
type TestJobFileInfo struct {
|
type TestJobFileInfo struct {
|
||||||
@@ -178,7 +177,7 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
log.SetLevel(logLevel)
|
log.SetLevel(logLevel)
|
||||||
|
|
||||||
workdir, err := filepath.Abs(j.workdir)
|
workdir, err := filepath.Abs(j.workdir)
|
||||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
fullWorkflowPath := filepath.Join(workdir, j.workflowPath)
|
fullWorkflowPath := filepath.Join(workdir, j.workflowPath)
|
||||||
runnerConfig := &Config{
|
runnerConfig := &Config{
|
||||||
@@ -188,31 +187,27 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
EventPath: cfg.EventPath,
|
EventPath: cfg.EventPath,
|
||||||
Platforms: j.platforms,
|
Platforms: j.platforms,
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
ForceRebuild: true,
|
|
||||||
Env: cfg.Env,
|
Env: cfg.Env,
|
||||||
Secrets: cfg.Secrets,
|
Secrets: cfg.Secrets,
|
||||||
Inputs: cfg.Inputs,
|
Inputs: cfg.Inputs,
|
||||||
GitHubInstance: "github.com",
|
GitHubInstance: "github.com",
|
||||||
DefaultActionInstance: cfg.DefaultActionInstance,
|
|
||||||
ContainerArchitecture: cfg.ContainerArchitecture,
|
ContainerArchitecture: cfg.ContainerArchitecture,
|
||||||
ContainerMaxLifetime: time.Hour,
|
|
||||||
Matrix: cfg.Matrix,
|
Matrix: cfg.Matrix,
|
||||||
ActionCache: cfg.ActionCache,
|
ActionCache: cfg.ActionCache,
|
||||||
ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runner, err := New(runnerConfig)
|
runner, err := New(runnerConfig)
|
||||||
assert.NoError(t, err, j.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err, j.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
||||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
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 = 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.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
} else {
|
} else {
|
||||||
assert.Error(t, err, j.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Error(t, err, j.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
@@ -226,14 +221,20 @@ type TestConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEvent(t *testing.T) {
|
func TestRunEvent(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
// Shells
|
// Shells
|
||||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||||
|
// TODO: figure out why it fails
|
||||||
|
// {workdir, "shells/custom", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, }, // custom image with pwsh
|
||||||
|
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
|
||||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||||
|
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:16-buster"}, secrets}, // slim doesn't have python
|
||||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// Local action
|
// Local action
|
||||||
@@ -245,6 +246,11 @@ func TestRunEvent(t *testing.T) {
|
|||||||
// Uses
|
// Uses
|
||||||
{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-nested-composite", "push", "", platforms, secrets},
|
||||||
|
{workdir, "remote-action-composite-js-pre-with-defaults", "push", "", platforms, secrets},
|
||||||
|
{workdir, "remote-action-composite-action-ref", "push", "", platforms, secrets},
|
||||||
|
{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}},
|
||||||
|
{workdir, "uses-workflow", "pull_request", "", platforms, map[string]string{"secret": "keep_it_private"}},
|
||||||
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||||
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
||||||
|
|
||||||
@@ -254,15 +260,21 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
||||||
{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, "issue-1195", "push", "", platforms, secrets},
|
||||||
|
|
||||||
{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, "runs-on", "push", "", platforms, secrets},
|
||||||
{workdir, "checkout", "push", "", 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-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, "remote-action-docker", "push", "", platforms, secrets},
|
||||||
|
{workdir, "remote-action-js", "push", "", platforms, secrets},
|
||||||
|
{workdir, "remote-action-js-node-user", "push", "", platforms, secrets}, // Test if this works with non root container
|
||||||
{workdir, "matrix", "push", "", platforms, secrets},
|
{workdir, "matrix", "push", "", platforms, secrets},
|
||||||
|
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
|
||||||
{workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets},
|
{workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets},
|
||||||
{workdir, "commands", "push", "", platforms, secrets},
|
{workdir, "commands", "push", "", platforms, secrets},
|
||||||
{workdir, "workdir", "push", "", platforms, secrets},
|
{workdir, "workdir", "push", "", platforms, secrets},
|
||||||
@@ -283,6 +295,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{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},
|
||||||
|
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
|
||||||
{workdir, "evalenv", "push", "", platforms, secrets},
|
{workdir, "evalenv", "push", "", platforms, secrets},
|
||||||
{workdir, "docker-action-custom-path", "push", "", platforms, secrets},
|
{workdir, "docker-action-custom-path", "push", "", platforms, secrets},
|
||||||
{workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets},
|
{workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets},
|
||||||
@@ -293,6 +306,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "workflow_dispatch-scalar", "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},
|
||||||
|
{"../model/testdata", "strategy", "push", "", platforms, secrets}, // TODO: move all testdata into pkg so we can validate it with planner and runner
|
||||||
{"../model/testdata", "container-volumes", "push", "", platforms, secrets},
|
{"../model/testdata", "container-volumes", "push", "", platforms, secrets},
|
||||||
{workdir, "path-handling", "push", "", platforms, secrets},
|
{workdir, "path-handling", "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},
|
||||||
@@ -302,6 +316,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
|
|
||||||
// services
|
// services
|
||||||
{workdir, "services", "push", "", platforms, secrets},
|
{workdir, "services", "push", "", platforms, secrets},
|
||||||
|
{workdir, "services-host-network", "push", "", platforms, secrets},
|
||||||
{workdir, "services-with-container", "push", "", platforms, secrets},
|
{workdir, "services-with-container", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// local remote action overrides
|
// local remote action overrides
|
||||||
@@ -310,11 +325,6 @@ func TestRunEvent(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
t.Run(table.workflowPath, func(t *testing.T) {
|
||||||
if table.workflowPath == "container-volumes" {
|
|
||||||
// host /proc bind mounts are Linux-Docker-only
|
|
||||||
requireLinuxDocker(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Secrets: table.secrets,
|
Secrets: table.secrets,
|
||||||
}
|
}
|
||||||
@@ -346,12 +356,9 @@ func TestRunEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventHostEnvironment(t *testing.T) {
|
func TestRunEventHostEnvironment(t *testing.T) {
|
||||||
// Runs steps directly on the host (the "-self-hosted" platform), so it needs the shells
|
if testing.Short() {
|
||||||
// and tools the workflows invoke. No network gate: every action these workflows reference
|
t.Skip("skipping integration test")
|
||||||
// is a local `./` fixture or the skipped actions/checkout, so the suite runs offline (same
|
}
|
||||||
// as TestRunEvent). Only the broadly-used interpreters are required up front; the pwsh- and
|
|
||||||
// nix-specific cases gate on their own tool below so a missing pwsh/nix skips just those.
|
|
||||||
requireHostTools(t, "bash", "node")
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -367,6 +374,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||||
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
||||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||||
|
{workdir, "shells/python", "push", "", platforms, secrets},
|
||||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// Local action
|
// Local action
|
||||||
@@ -375,6 +383,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
// Uses
|
// Uses
|
||||||
{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-nested-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// Eval
|
// Eval
|
||||||
@@ -383,10 +392,14 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
||||||
{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, "issue-1195", "push", "", platforms, secrets},
|
||||||
|
|
||||||
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
||||||
|
{workdir, "runs-on", "push", "", platforms, secrets},
|
||||||
{workdir, "checkout", "push", "", platforms, secrets},
|
{workdir, "checkout", "push", "", platforms, secrets},
|
||||||
|
{workdir, "remote-action-js", "push", "", platforms, secrets},
|
||||||
{workdir, "matrix", "push", "", platforms, secrets},
|
{workdir, "matrix", "push", "", platforms, secrets},
|
||||||
|
{workdir, "matrix-include-exclude", "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},
|
||||||
@@ -400,6 +413,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
{workdir, "steps-context/outcome", "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, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
|
||||||
{workdir, "evalenv", "push", "", platforms, secrets},
|
{workdir, "evalenv", "push", "", platforms, secrets},
|
||||||
{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},
|
||||||
}...)
|
}...)
|
||||||
@@ -432,26 +446,24 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
|
|
||||||
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 {
|
|
||||||
case "shells/pwsh":
|
|
||||||
requireHostTools(t, "pwsh")
|
|
||||||
case "nix-prepend-path":
|
|
||||||
requireHostTools(t, "nix")
|
|
||||||
}
|
|
||||||
table.runTest(ctx, t, &Config{})
|
table.runTest(ctx, t, &Config{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDryrunEvent(t *testing.T) {
|
func TestDryrunEvent(t *testing.T) {
|
||||||
// Dryrun plans without containers or network (shells and local actions only).
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
|
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
// Shells
|
// Shells
|
||||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||||
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
|
||||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||||
|
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:16-buster"}, secrets}, // slim doesn't have python
|
||||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// Local action
|
// Local action
|
||||||
@@ -468,18 +480,10 @@ func TestDryrunEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReusableWorkflowCaller exercises the reusable-workflow caller path against a local
|
|
||||||
// reusable workflow (typed inputs, secrets as both a map and `inherit`, and reading the called
|
|
||||||
// workflow's outputs via `needs`).
|
|
||||||
func TestReusableWorkflowCaller(t *testing.T) {
|
|
||||||
requireDocker(t)
|
|
||||||
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
|
||||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
requireNetwork(t) // force-pulls a docker action image
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -500,6 +504,22 @@ func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunDifferentArchitecture(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tjfi := TestJobFileInfo{
|
||||||
|
workdir: workdir,
|
||||||
|
workflowPath: "basic",
|
||||||
|
eventName: "push",
|
||||||
|
errorMessage: "",
|
||||||
|
platforms: platforms,
|
||||||
|
}
|
||||||
|
|
||||||
|
tjfi.runTest(context.Background(), t, &Config{ContainerArchitecture: "linux/arm64"})
|
||||||
|
}
|
||||||
|
|
||||||
type maskJobLoggerFactory struct {
|
type maskJobLoggerFactory struct {
|
||||||
Output bytes.Buffer
|
Output bytes.Buffer
|
||||||
}
|
}
|
||||||
@@ -520,7 +540,9 @@ func TestMaskValues(t *testing.T) {
|
|||||||
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
@@ -541,7 +563,9 @@ func TestMaskValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventSecrets(t *testing.T) {
|
func TestRunEventSecrets(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
workflowPath := "secrets"
|
workflowPath := "secrets"
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
tjfi := TestJobFileInfo{
|
||||||
@@ -561,13 +585,15 @@ func TestRunEventSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunWithService(t *testing.T) {
|
func TestRunWithService(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
platforms := map[string]string{
|
platforms := map[string]string{
|
||||||
"ubuntu-latest": "node:24-bookworm-slim",
|
"ubuntu-latest": "node:12.20.1-buster-slim",
|
||||||
}
|
}
|
||||||
|
|
||||||
workflowPath := "services"
|
workflowPath := "services"
|
||||||
@@ -577,11 +603,10 @@ func TestRunWithService(t *testing.T) {
|
|||||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
runnerConfig := &Config{
|
runnerConfig := &Config{
|
||||||
Workdir: workdir,
|
Workdir: workdir,
|
||||||
EventName: eventName,
|
EventName: eventName,
|
||||||
Platforms: platforms,
|
Platforms: platforms,
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
|
|
||||||
}
|
}
|
||||||
runner, err := New(runnerConfig)
|
runner, err := New(runnerConfig)
|
||||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
@@ -597,7 +622,9 @@ func TestRunWithService(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunActionInputs(t *testing.T) {
|
func TestRunActionInputs(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
workflowPath := "input-from-cli"
|
workflowPath := "input-from-cli"
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
tjfi := TestJobFileInfo{
|
||||||
@@ -616,7 +643,9 @@ func TestRunActionInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventPullRequest(t *testing.T) {
|
func TestRunEventPullRequest(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
workflowPath := "pull-request"
|
workflowPath := "pull-request"
|
||||||
|
|
||||||
@@ -632,7 +661,9 @@ func TestRunEventPullRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
workflowPath := "matrix-with-user-inclusions"
|
workflowPath := "matrix-with-user-inclusions"
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
tjfi := TestJobFileInfo{
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if strings.Contains(stepString, "::add-mask::") {
|
if strings.Contains(stepString, "::add-mask::") {
|
||||||
stepString = "add-mask command"
|
stepString = "add-mask command"
|
||||||
}
|
}
|
||||||
logger.Infof("Run %s %s", stage, stepString)
|
logger.Infof("\u2B50 Run %s %s", stage, stepString)
|
||||||
|
|
||||||
// Prepare and clean Runner File Commands
|
// Prepare and clean Runner File Commands
|
||||||
actPath := rc.JobContainer.GetActPath()
|
actPath := rc.JobContainer.GetActPath()
|
||||||
@@ -158,7 +158,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
err = executor(timeoutctx)
|
err = executor(timeoutctx)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
|
logger.WithField("stepResult", stepResult.Outcome).Infof(" \u2705 Success - %s %s", stage, stepString)
|
||||||
} else {
|
} else {
|
||||||
stepResult.Outcome = model.StepStatusFailure
|
stepResult.Outcome = model.StepStatusFailure
|
||||||
|
|
||||||
@@ -169,7 +169,6 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if continueOnError {
|
if continueOnError {
|
||||||
logger.Errorf("##[error]%v", err)
|
|
||||||
logger.Infof("Failed but continue next step")
|
logger.Infof("Failed but continue next step")
|
||||||
err = nil
|
err = nil
|
||||||
stepResult.Conclusion = model.StepStatusSuccess
|
stepResult.Conclusion = model.StepStatusSuccess
|
||||||
@@ -177,9 +176,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
stepResult.Conclusion = model.StepStatusFailure
|
stepResult.Conclusion = model.StepStatusFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infof: Errorf entries are promoted to the user log by the reporter,
|
logger.WithField("stepResult", stepResult.Outcome).Errorf(" \u274C Failure - %s %s", stage, stepString)
|
||||||
// which would duplicate the ##[error] annotation emitted elsewhere.
|
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Failure - %s %s", stage, stepString)
|
|
||||||
}
|
}
|
||||||
// Process Runner File Commands
|
// Process Runner File Commands
|
||||||
orgerr := err
|
orgerr := err
|
||||||
@@ -271,7 +268,7 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage)
|
|||||||
|
|
||||||
runStep, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, defaultStatusCheck)
|
runStep, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, defaultStatusCheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
|
return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", expr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return runStep, nil
|
return runStep, nil
|
||||||
@@ -287,7 +284,7 @@ func isContinueOnError(ctx context.Context, expr string, step step, _ stepStage)
|
|||||||
|
|
||||||
continueOnError, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, exprparser.DefaultStatusCheckNone)
|
continueOnError, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, exprparser.DefaultStatusCheckNone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("continue-on-error expression %q evaluation failed: %s", expr, err)
|
return false, fmt.Errorf(" \u274C Error in continue-on-error-expression: \"continue-on-error: %s\" (%s)", expr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return continueOnError, nil
|
return continueOnError, nil
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ func (sal *stepActionLocal) main() common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
printRunActionHeader(ctx, sal.Step, sal.env, sal.getRunContext())
|
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
|
||||||
defer rawLogger.Infof("::endgroup::")
|
|
||||||
|
|
||||||
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
|
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
|
||||||
|
|
||||||
localReader := func(ctx context.Context) actionYamlReader {
|
localReader := func(ctx context.Context) actionYamlReader {
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ func TestStepActionLocalTest(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
err := sal.pre()(ctx)
|
err := sal.pre()(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
err = sal.main()(ctx)
|
err = sal.main()(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
salm.AssertExpectations(t)
|
salm.AssertExpectations(t)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type stepActionRemote struct {
|
|||||||
|
|
||||||
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
|
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
|
||||||
|
|
||||||
|
//nolint:gocyclo // function handles many cases
|
||||||
func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if sar.remoteAction != nil && sar.action != nil {
|
if sar.remoteAction != nil && sar.action != nil {
|
||||||
@@ -113,10 +114,9 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance))
|
||||||
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(defaultActionURL))
|
|
||||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
||||||
URL: sar.remoteAction.CloneURL(defaultActionURL),
|
URL: sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance),
|
||||||
Ref: sar.remoteAction.Ref,
|
Ref: sar.remoteAction.Ref,
|
||||||
Dir: actionDir,
|
Dir: actionDir,
|
||||||
Token: token,
|
Token: token,
|
||||||
@@ -146,7 +146,6 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
ntErr,
|
ntErr,
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
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(ctx), os.WriteFile)
|
||||||
sar.action = actionModel
|
sar.action = actionModel
|
||||||
return err
|
return err
|
||||||
@@ -167,10 +166,6 @@ func (sar *stepActionRemote) main() common.Executor {
|
|||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
sar.prepareActionExecutor(),
|
sar.prepareActionExecutor(),
|
||||||
runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
|
runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
|
||||||
printRunActionHeader(ctx, sar.Step, sar.env, sar.RunContext)
|
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
|
||||||
defer rawLogger.Infof("::endgroup::")
|
|
||||||
|
|
||||||
github := sar.getGithubContext(ctx)
|
github := sar.getGithubContext(ctx)
|
||||||
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
||||||
if sar.RunContext.Config.BindWorkdir {
|
if sar.RunContext.Config.BindWorkdir {
|
||||||
@@ -275,7 +270,7 @@ func (sar *stepActionRemote) cloneSkipTLS() bool {
|
|||||||
if sar.remoteAction.URL == "" {
|
if sar.remoteAction.URL == "" {
|
||||||
// Empty URL means the default action instance should be used
|
// Empty URL means the default action instance should be used
|
||||||
// Return true if the URL of the Gitea instance is the same as the URL of the default action instance
|
// Return true if the URL of the Gitea instance is the same as the URL of the default action instance
|
||||||
return sar.RunContext.Config.DefaultActionURL() == sar.RunContext.Config.GitHubInstance
|
return sar.RunContext.Config.DefaultActionInstance == sar.RunContext.Config.GitHubInstance
|
||||||
}
|
}
|
||||||
// Return true if the URL of the remote action is the same as the URL of the Gitea instance
|
// Return true if the URL of the remote action is the same as the URL of the Gitea instance
|
||||||
return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance
|
return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance
|
||||||
@@ -291,9 +286,7 @@ type remoteAction struct {
|
|||||||
|
|
||||||
func (ra *remoteAction) CloneURL(u string) string {
|
func (ra *remoteAction) CloneURL(u string) string {
|
||||||
if ra.URL == "" {
|
if ra.URL == "" {
|
||||||
// keep an absolute local path as-is (used by tests to resolve actions from a local
|
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||||
// repo); only bare host names get the https:// scheme prepended
|
|
||||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !filepath.IsAbs(u) {
|
|
||||||
u = "https://" + u
|
u = "https://" + u
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -273,8 +272,8 @@ func TestStepActionRemotePre(t *testing.T) {
|
|||||||
|
|
||||||
err := sar.pre()(ctx)
|
err := sar.pre()(ctx)
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.True(t, clonedAction)
|
assert.Equal(t, true, clonedAction) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
sarm.AssertExpectations(t)
|
sarm.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
@@ -344,8 +343,8 @@ func TestStepActionRemotePreThroughAction(t *testing.T) {
|
|||||||
|
|
||||||
err := sar.pre()(ctx)
|
err := sar.pre()(ctx)
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.True(t, clonedAction)
|
assert.Equal(t, true, clonedAction) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
sarm.AssertExpectations(t)
|
sarm.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
@@ -420,7 +419,7 @@ func TestStepActionRemotePreThroughActionToken(t *testing.T) {
|
|||||||
|
|
||||||
err := sar.pre()(ctx)
|
err := sar.pre()(ctx)
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
// Verify that the clone was called (URL should be redirected to github.com)
|
// 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.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")
|
assert.Equal(t, "https://github.com/org/repo", actualURL, "URL should be redirected to github.com")
|
||||||
@@ -435,57 +434,6 @@ func TestStepActionRemotePreThroughActionToken(t *testing.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{
|
|
||||||
Step: &model.Step{
|
|
||||||
Uses: "actions/setup-go@v4",
|
|
||||||
},
|
|
||||||
RunContext: &RunContext{
|
|
||||||
Config: &Config{
|
|
||||||
GitHubInstance: "gitea.example",
|
|
||||||
DefaultActionInstance: "",
|
|
||||||
ActionCacheDir: t.TempDir(),
|
|
||||||
},
|
|
||||||
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()), "", 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) {
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -116,10 +116,6 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
|||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts()
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
|
||||||
if rc.IsHostEnv(ctx) {
|
|
||||||
networkMode = "default"
|
|
||||||
}
|
|
||||||
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
Entrypoint: entrypoint,
|
Entrypoint: entrypoint,
|
||||||
@@ -127,10 +123,10 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
|||||||
Image: image,
|
Image: image,
|
||||||
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
||||||
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
||||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
|
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+step.ID),
|
||||||
Env: envList,
|
Env: envList,
|
||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
NetworkMode: networkMode,
|
NetworkMode: "container:" + rc.jobContainerName(),
|
||||||
Binds: binds,
|
Binds: binds,
|
||||||
Stdout: logWriter,
|
Stdout: logWriter,
|
||||||
Stderr: logWriter,
|
Stderr: logWriter,
|
||||||
@@ -138,8 +134,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
|||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
AutoRemove: rc.Config.AutoRemove,
|
AutoRemove: rc.Config.AutoRemove,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.Config.ValidVolumes,
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
|
||||||
})
|
})
|
||||||
return stepContainer
|
return stepContainer
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
@@ -102,147 +101,20 @@ func TestStepDockerMain(t *testing.T) {
|
|||||||
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)
|
||||||
|
|
||||||
err := sd.main()(ctx)
|
err := sd.main()(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
assert.Equal(t, "node:14", input.Image)
|
assert.Equal(t, "node:14", input.Image)
|
||||||
|
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
allocPTY bool
|
|
||||||
}{
|
|
||||||
{name: "off", allocPTY: false},
|
|
||||||
{name: "on", allocPTY: true},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
cm := &containerMock{}
|
|
||||||
|
|
||||||
var captured *container.NewContainerInput
|
|
||||||
origContainerNewContainer := ContainerNewContainer
|
|
||||||
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
|
||||||
captured = input
|
|
||||||
return cm
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
ContainerNewContainer = origContainerNewContainer
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
sd := &stepDocker{
|
|
||||||
RunContext: &RunContext{
|
|
||||||
StepResults: map[string]*model.StepResult{},
|
|
||||||
Config: &Config{
|
|
||||||
AllocatePTY: tc.allocPTY,
|
|
||||||
PlatformPicker: func(_ []string) string {
|
|
||||||
return "node:14"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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"},
|
|
||||||
}
|
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
|
||||||
|
|
||||||
_ = sd.newStepContainer(ctx, "node:14", []string{"echo", "hi"}, nil)
|
|
||||||
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepDockerPrePost(t *testing.T) {
|
func TestStepDockerPrePost(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
sd := &stepDocker{}
|
sd := &stepDocker{}
|
||||||
|
|
||||||
err := sd.pre()(ctx)
|
err := sd.pre()(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
err = sd.post()(ctx)
|
err = sd.post()(ctx)
|
||||||
assert.NoError(t, err)
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
platform string
|
|
||||||
expectDefault bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "docker mode attaches to job container network",
|
|
||||||
platform: "node:14",
|
|
||||||
expectDefault: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "host mode uses default network",
|
|
||||||
platform: "-self-hosted",
|
|
||||||
expectDefault: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
cm := &containerMock{}
|
|
||||||
|
|
||||||
var captured *container.NewContainerInput
|
|
||||||
origContainerNewContainer := ContainerNewContainer
|
|
||||||
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
|
||||||
captured = input
|
|
||||||
return cm
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
ContainerNewContainer = origContainerNewContainer
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
platform := tc.platform
|
|
||||||
sd := &stepDocker{
|
|
||||||
RunContext: &RunContext{
|
|
||||||
StepResults: map[string]*model.StepResult{},
|
|
||||||
Config: &Config{
|
|
||||||
PlatformPicker: func(_ []string) string {
|
|
||||||
return platform
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "1",
|
|
||||||
Workflow: &model.Workflow{
|
|
||||||
Jobs: map[string]*model.Job{
|
|
||||||
"1": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
JobContainer: cm,
|
|
||||||
},
|
|
||||||
Step: &model.Step{
|
|
||||||
ID: "1",
|
|
||||||
Uses: "docker://alpine:3.20",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
|
||||||
|
|
||||||
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx),
|
|
||||||
"IsHostEnv mismatch for platform %q", tc.platform)
|
|
||||||
|
|
||||||
_ = sd.newStepContainer(ctx, "alpine:3.20", []string{"echo", "hello"}, nil)
|
|
||||||
|
|
||||||
if tc.expectDefault {
|
|
||||||
assert.Equal(t, "default", captured.NetworkMode,
|
|
||||||
"host-mode step container must use 'default' network, got %q",
|
|
||||||
captured.NetworkMode)
|
|
||||||
} else {
|
|
||||||
assert.True(t, strings.HasPrefix(captured.NetworkMode, "container:"),
|
|
||||||
"docker-mode step container must attach to job container network, got %q",
|
|
||||||
captured.NetworkMode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func TestStepFactoryNewStep(t *testing.T) {
|
|||||||
step, err := sf.newStep(tt.model, &RunContext{})
|
step, err := sf.newStep(tt.model, &RunContext{})
|
||||||
|
|
||||||
assert.True(t, tt.check((step)))
|
assert.True(t, tt.check((step)))
|
||||||
assert.NoError(t, err)
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -18,18 +17,15 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type stepRun struct {
|
type stepRun struct {
|
||||||
Step *model.Step
|
Step *model.Step
|
||||||
RunContext *RunContext
|
RunContext *RunContext
|
||||||
cmd []string
|
cmd []string
|
||||||
cmdline string
|
cmdline string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
WorkingDirectory string
|
WorkingDirectory string
|
||||||
interpolatedScript string
|
|
||||||
shellCommand string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sr *stepRun) pre() common.Executor {
|
func (sr *stepRun) pre() common.Executor {
|
||||||
@@ -43,154 +39,15 @@ func (sr *stepRun) main() common.Executor {
|
|||||||
return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor(
|
return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor(
|
||||||
sr.setupShellCommandExecutor(),
|
sr.setupShellCommandExecutor(),
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
rc := sr.getRunContext()
|
sr.getRunContext().ApplyExtraPath(ctx, &sr.env)
|
||||||
// Apply ::add-path:: effects before printing so PATH is accurate in the env: block.
|
if he, ok := sr.getRunContext().JobContainer.(*container.HostEnvironment); ok && he != nil {
|
||||||
rc.ApplyExtraPath(ctx, &sr.env)
|
|
||||||
sr.printRunScriptActionDetails(ctx)
|
|
||||||
if he, ok := rc.JobContainer.(*container.HostEnvironment); ok && he != nil {
|
|
||||||
return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, "", sr.WorkingDirectory)(ctx)
|
return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, "", sr.WorkingDirectory)(ctx)
|
||||||
}
|
}
|
||||||
return rc.JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx)
|
return sr.getRunContext().JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx)
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// printRunScriptActionDetails mirrors actions/runner ScriptHandler.PrintActionDetails
|
|
||||||
// for script steps.
|
|
||||||
func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
|
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
|
||||||
scriptLineLogger := rawLogger.WithField(scriptLineCyanField, true)
|
|
||||||
|
|
||||||
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
|
||||||
|
|
||||||
rawLogger.Infof("::group::Run %s", sr.runScriptGroupTitle(normalized))
|
|
||||||
|
|
||||||
if normalized != "" {
|
|
||||||
for line := range strings.SplitSeq(normalized, "\n") {
|
|
||||||
scriptLineLogger.Info(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rawLogger.Infof("shell: %s", sr.shellCommand)
|
|
||||||
|
|
||||||
printStepEnvBlock(ctx, sr.Step, sr.env, sr.getRunContext())
|
|
||||||
rawLogger.Infof("::endgroup::")
|
|
||||||
}
|
|
||||||
|
|
||||||
// printRunActionHeader mirrors actions/runner's "Run <action>" header for `uses:` steps,
|
|
||||||
// including the with: inputs and the step-level env: block. The caller is responsible
|
|
||||||
// for emitting ::endgroup:: after the action finishes.
|
|
||||||
func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
|
|
||||||
if step == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
|
||||||
|
|
||||||
title := step.Uses
|
|
||||||
if step.Name != "" {
|
|
||||||
title = step.Name
|
|
||||||
}
|
|
||||||
rawLogger.Infof("::group::Run %s", title)
|
|
||||||
|
|
||||||
if len(step.With) > 0 {
|
|
||||||
rawLogger.Infof("with:")
|
|
||||||
for _, k := range slices.Sorted(maps.Keys(step.With)) {
|
|
||||||
rawLogger.Infof(" %s: %s", k, step.With[k])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printStepEnvBlock(ctx, step, env, rc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// printStepEnvBlock emits the declared-env block (YAML order, internal vars filtered)
|
|
||||||
// shared by the run: and uses: "Run" headers.
|
|
||||||
func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
|
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
|
||||||
caseInsensitive := rc != nil && rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive()
|
|
||||||
var visible []string
|
|
||||||
for _, k := range stepDeclaredEnvKeysInOrder(step) {
|
|
||||||
if !isInternalEnvKey(k, caseInsensitive) {
|
|
||||||
visible = append(visible, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(visible) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rawLogger.Infof("env:")
|
|
||||||
envLookup := env
|
|
||||||
if caseInsensitive {
|
|
||||||
envLookup = make(map[string]string, len(env))
|
|
||||||
for k, v := range env {
|
|
||||||
envLookup[strings.ToUpper(k)] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, k := range visible {
|
|
||||||
lookupKey := k
|
|
||||||
if caseInsensitive {
|
|
||||||
lookupKey = strings.ToUpper(k)
|
|
||||||
}
|
|
||||||
rawLogger.Infof(" %s: %s", k, envLookup[lookupKey])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isInternalEnvKey matches actions/runner's filtered set of vars that are hidden
|
|
||||||
// from the "Run" header's env: block because they are injected by the runner itself.
|
|
||||||
func isInternalEnvKey(k string, caseInsensitive bool) bool {
|
|
||||||
upper := k
|
|
||||||
if caseInsensitive {
|
|
||||||
upper = strings.ToUpper(k)
|
|
||||||
}
|
|
||||||
switch upper {
|
|
||||||
case "PATH", "HOME", "CI":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.HasPrefix(upper, "GITHUB_") ||
|
|
||||||
strings.HasPrefix(upper, "GITEA_") ||
|
|
||||||
strings.HasPrefix(upper, "RUNNER_") ||
|
|
||||||
strings.HasPrefix(upper, "INPUT_")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sr *stepRun) runScriptGroupTitle(normalizedScript string) string {
|
|
||||||
trimmed := strings.TrimLeft(normalizedScript, " \t\r\n")
|
|
||||||
if idx := strings.IndexAny(trimmed, "\r\n"); idx >= 0 {
|
|
||||||
trimmed = trimmed[:idx]
|
|
||||||
}
|
|
||||||
if trimmed != "" {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
if sr.Step != nil {
|
|
||||||
if sr.Step.Name != "" {
|
|
||||||
return sr.Step.Name
|
|
||||||
}
|
|
||||||
return sr.Step.ID
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// stepDeclaredEnvKeysInOrder walks the raw YAML Env mapping so keys are emitted in
|
|
||||||
// the order the workflow author wrote them; step.Environment() decodes into a Go map
|
|
||||||
// and loses ordering.
|
|
||||||
func stepDeclaredEnvKeysInOrder(step *model.Step) []string {
|
|
||||||
if step == nil || step.Env.Kind != yaml.MappingNode {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content := step.Env.Content
|
|
||||||
keys := make([]string, 0, len(content)/2)
|
|
||||||
seen := make(map[string]struct{}, len(content)/2)
|
|
||||||
for i := 0; i+1 < len(content); i += 2 {
|
|
||||||
k := content[i]
|
|
||||||
if k.Kind != yaml.ScalarNode || k.Tag == "!!merge" || k.Value == "<<" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, dup := seen[k.Value]; dup {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[k.Value] = struct{}{}
|
|
||||||
keys = append(keys, k.Value)
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sr *stepRun) post() common.Executor {
|
func (sr *stepRun) post() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
@@ -254,10 +111,8 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
|
|||||||
step := sr.Step
|
step := sr.Step
|
||||||
|
|
||||||
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run)
|
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run)
|
||||||
sr.interpolatedScript = script
|
|
||||||
|
|
||||||
scCmd := step.ShellCommand()
|
scCmd := step.ShellCommand()
|
||||||
sr.shellCommand = scCmd
|
|
||||||
|
|
||||||
name = getScriptName(sr.RunContext, step)
|
name = getScriptName(sr.RunContext, step)
|
||||||
|
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2026 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
yaml "go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRunScriptGroupTitle(t *testing.T) {
|
|
||||||
sr := &stepRun{Step: &model.Step{Name: "Build"}}
|
|
||||||
assert.Equal(t, "make build", sr.runScriptGroupTitle("make build"))
|
|
||||||
assert.Equal(t, "echo one", sr.runScriptGroupTitle(" \techo one\necho two"))
|
|
||||||
assert.Equal(t, "Build", sr.runScriptGroupTitle(""))
|
|
||||||
|
|
||||||
sr = &stepRun{Step: &model.Step{ID: "s1"}}
|
|
||||||
assert.Equal(t, "s1", sr.runScriptGroupTitle("\n \n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepDeclaredEnvOrderPreservesYAML(t *testing.T) {
|
|
||||||
raw := `id: s1
|
|
||||||
run: "echo 1"
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: tok
|
|
||||||
PATH: /custom/bin
|
|
||||||
MY_VAR: hello
|
|
||||||
`
|
|
||||||
var step model.Step
|
|
||||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
|
||||||
assert.Equal(t, []string{"GITHUB_TOKEN", "PATH", "MY_VAR"}, stepDeclaredEnvKeysInOrder(&step))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepDeclaredEnvKeysInOrderEmpty(t *testing.T) {
|
|
||||||
assert.Nil(t, stepDeclaredEnvKeysInOrder(nil))
|
|
||||||
assert.Empty(t, stepDeclaredEnvKeysInOrder(&model.Step{}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStepDeclaredEnvKeysIgnoreYAMLMergeKey(t *testing.T) {
|
|
||||||
doc := `
|
|
||||||
common: &common
|
|
||||||
COMMON_A: a
|
|
||||||
COMMON_B: b
|
|
||||||
step:
|
|
||||||
env:
|
|
||||||
LOCAL_BEFORE: before
|
|
||||||
<<: *common
|
|
||||||
COMMON_B: overridden
|
|
||||||
LOCAL_AFTER: after
|
|
||||||
`
|
|
||||||
var root struct {
|
|
||||||
Step model.Step `yaml:"step"`
|
|
||||||
}
|
|
||||||
require.NoError(t, yaml.Unmarshal([]byte(doc), &root))
|
|
||||||
|
|
||||||
keys := stepDeclaredEnvKeysInOrder(&root.Step)
|
|
||||||
assert.Equal(t, []string{"LOCAL_BEFORE", "COMMON_B", "LOCAL_AFTER"}, keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrintRunScriptActionDetailsGolden(t *testing.T) {
|
|
||||||
raw := `id: s1
|
|
||||||
name: Build
|
|
||||||
run: |
|
|
||||||
echo one
|
|
||||||
echo two
|
|
||||||
shell: pwsh
|
|
||||||
env:
|
|
||||||
PATH_PREFIX: /custom/bin
|
|
||||||
GITHUB_TOKEN: tok
|
|
||||||
GREETING: hello
|
|
||||||
`
|
|
||||||
var step model.Step
|
|
||||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
|
||||||
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
logger := logrus.New()
|
|
||||||
logger.SetOutput(buf)
|
|
||||||
logger.SetLevel(logrus.InfoLevel)
|
|
||||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
|
||||||
entry := logger.WithFields(logrus.Fields{"job": "j1"})
|
|
||||||
ctx := common.WithLogger(context.Background(), entry)
|
|
||||||
|
|
||||||
sr := &stepRun{
|
|
||||||
Step: &step,
|
|
||||||
RunContext: &RunContext{},
|
|
||||||
shellCommand: "pwsh -command . '{0}'",
|
|
||||||
interpolatedScript: "echo one\necho two\n",
|
|
||||||
env: map[string]string{
|
|
||||||
"PATH_PREFIX": "/custom/bin",
|
|
||||||
"GITHUB_TOKEN": "tok",
|
|
||||||
"GREETING": "hello",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
sr.printRunScriptActionDetails(ctx)
|
|
||||||
|
|
||||||
want := strings.Join([]string{
|
|
||||||
"[j1] | ::group::Run echo one",
|
|
||||||
"[j1] | echo one",
|
|
||||||
"[j1] | echo two",
|
|
||||||
"[j1] | shell: pwsh -command . '{0}'",
|
|
||||||
"[j1] | env:",
|
|
||||||
"[j1] | PATH_PREFIX: /custom/bin",
|
|
||||||
"[j1] | GREETING: hello",
|
|
||||||
"[j1] | ::endgroup::",
|
|
||||||
"",
|
|
||||||
}, "\n")
|
|
||||||
assert.Equal(t, want, buf.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrintRunActionHeaderGolden(t *testing.T) {
|
|
||||||
raw := `id: s1
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: "0"
|
|
||||||
token: secret
|
|
||||||
env:
|
|
||||||
CUSTOM: value
|
|
||||||
GITHUB_TOKEN: tok
|
|
||||||
`
|
|
||||||
var step model.Step
|
|
||||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
|
||||||
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
logger := logrus.New()
|
|
||||||
logger.SetOutput(buf)
|
|
||||||
logger.SetLevel(logrus.InfoLevel)
|
|
||||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
|
||||||
entry := logger.WithFields(logrus.Fields{"job": "j1"})
|
|
||||||
ctx := common.WithLogger(context.Background(), entry)
|
|
||||||
|
|
||||||
printRunActionHeader(ctx, &step, map[string]string{"CUSTOM": "value", "GITHUB_TOKEN": "tok"}, &RunContext{})
|
|
||||||
|
|
||||||
want := strings.Join([]string{
|
|
||||||
"[j1] | ::group::Run actions/checkout@v4",
|
|
||||||
"[j1] | with:",
|
|
||||||
"[j1] | fetch-depth: 0",
|
|
||||||
"[j1] | token: secret",
|
|
||||||
"[j1] | env:",
|
|
||||||
"[j1] | CUSTOM: value",
|
|
||||||
"",
|
|
||||||
}, "\n")
|
|
||||||
assert.Equal(t, want, buf.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsInternalEnvKey(t *testing.T) {
|
|
||||||
for _, k := range []string{"PATH", "HOME", "CI", "GITHUB_TOKEN", "GITEA_ACTIONS", "RUNNER_OS", "INPUT_FOO"} {
|
|
||||||
assert.True(t, isInternalEnvKey(k, false), k)
|
|
||||||
}
|
|
||||||
for _, k := range []string{"PATH_PREFIX", "MY_VAR", "GREETING", "HOMEPAGE"} {
|
|
||||||
assert.False(t, isInternalEnvKey(k, false), k)
|
|
||||||
}
|
|
||||||
assert.True(t, isInternalEnvKey("path", true))
|
|
||||||
assert.False(t, isInternalEnvKey("path", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrintColoredScriptLineCyan(t *testing.T) {
|
|
||||||
f := &jobLogFormatter{color: cyan}
|
|
||||||
entry := &logrus.Entry{
|
|
||||||
Level: logrus.InfoLevel,
|
|
||||||
Message: "echo one",
|
|
||||||
Data: logrus.Fields{
|
|
||||||
"job": "j1",
|
|
||||||
rawOutputField: true,
|
|
||||||
scriptLineCyanField: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
f.printColored(buf, entry)
|
|
||||||
assert.Equal(t, "\x1b[36m|\x1b[0m \x1b[36;1mecho one\x1b[0m", buf.String())
|
|
||||||
}
|
|
||||||
@@ -81,7 +81,7 @@ func TestStepRun(t *testing.T) {
|
|||||||
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)
|
||||||
|
|
||||||
err := sr.main()(ctx)
|
err := sr.main()(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
@@ -91,8 +91,8 @@ func TestStepRunPrePost(t *testing.T) {
|
|||||||
sr := &stepRun{}
|
sr := &stepRun{}
|
||||||
|
|
||||||
err := sr.pre()(ctx)
|
err := sr.pre()(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
err = sr.post()(ctx)
|
err = sr.post()(ctx)
|
||||||
assert.NoError(t, err)
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
sm.On("getEnv").Return(&env)
|
sm.On("getEnv").Return(&env)
|
||||||
|
|
||||||
err := setupEnv(context.Background(), sm)
|
err := setupEnv(context.Background(), sm)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// These are commit or system specific
|
// These are commit or system specific
|
||||||
delete((env), "GITHUB_REF")
|
delete((env), "GITHUB_REF")
|
||||||
@@ -318,35 +318,35 @@ func TestIsContinueOnError(t *testing.T) {
|
|||||||
step := createTestStep(t, "name: test")
|
step := createTestStep(t, "name: test")
|
||||||
continueOnError, err := isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
continueOnError, err := isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||||
assertObject.False(continueOnError)
|
assertObject.False(continueOnError)
|
||||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// explcit true
|
// explcit true
|
||||||
step = createTestStep(t, "continue-on-error: true")
|
step = createTestStep(t, "continue-on-error: true")
|
||||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||||
assertObject.True(continueOnError)
|
assertObject.True(continueOnError)
|
||||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// explicit false
|
// explicit false
|
||||||
step = createTestStep(t, "continue-on-error: false")
|
step = createTestStep(t, "continue-on-error: false")
|
||||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||||
assertObject.False(continueOnError)
|
assertObject.False(continueOnError)
|
||||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// expression true
|
// expression true
|
||||||
step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}")
|
step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}")
|
||||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||||
assertObject.True(continueOnError)
|
assertObject.True(continueOnError)
|
||||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// expression false
|
// expression false
|
||||||
step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}")
|
step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}")
|
||||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||||
assertObject.False(continueOnError)
|
assertObject.False(continueOnError)
|
||||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
// expression parse error
|
// expression parse error
|
||||||
step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}")
|
step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}")
|
||||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||||
assertObject.False(continueOnError)
|
assertObject.False(continueOnError)
|
||||||
assertObject.Error(err)
|
assertObject.NotNil(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
name: local-reusable-workflow
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
string_required:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
bool_required:
|
|
||||||
required: true
|
|
||||||
type: boolean
|
|
||||||
number_required:
|
|
||||||
required: true
|
|
||||||
type: number
|
|
||||||
secrets:
|
|
||||||
secret:
|
|
||||||
required: true
|
|
||||||
outputs:
|
|
||||||
output:
|
|
||||||
value: ${{ jobs.reusable.outputs.output }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
reusable:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
output: ${{ steps.gen.outputs.output }}
|
|
||||||
steps:
|
|
||||||
- name: check inputs and secret arrived
|
|
||||||
run: |
|
|
||||||
[ "${{ inputs.string_required }}" = "string" ]
|
|
||||||
[ "${{ inputs.bool_required }}" = "true" ]
|
|
||||||
[ "${{ inputs.number_required }}" = "1" ]
|
|
||||||
[ "${{ secrets.secret }}" = "keep_it_private" ]
|
|
||||||
- id: gen
|
|
||||||
run: echo "output=${{ inputs.string_required }}" >> $GITHUB_OUTPUT
|
|
||||||
@@ -5,11 +5,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
MYGLOBALENV3: myglobalval3
|
MYGLOBALENV3: myglobalval3
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- run: |
|
- run: |
|
||||||
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
|
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
|
||||||
echo "::set-env name=MYGLOBALENV2::myglobalval2"
|
echo "::set-env name=MYGLOBALENV2::myglobalval2"
|
||||||
- uses: ./actions/script
|
- uses: nektos/act-test-actions/script@main
|
||||||
with:
|
with:
|
||||||
main: |
|
main: |
|
||||||
env
|
env
|
||||||
|
|||||||
41
act/runner/testdata/GITHUB_STATE/push.yml
vendored
41
act/runner/testdata/GITHUB_STATE/push.yml
vendored
@@ -1,31 +1,48 @@
|
|||||||
on: push
|
on: push
|
||||||
jobs:
|
jobs:
|
||||||
# State saved in main (via the $GITHUB_STATE file and the ::save-state command) must surface
|
|
||||||
# as $STATE_* in the action's post step.
|
|
||||||
_:
|
_:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: nektos/act-test-actions/script@main
|
||||||
- uses: ./actions/script
|
|
||||||
with:
|
with:
|
||||||
|
pre: |
|
||||||
|
env
|
||||||
|
echo mystate0=mystateval > $GITHUB_STATE
|
||||||
|
echo "::save-state name=mystate1::mystateval"
|
||||||
main: |
|
main: |
|
||||||
|
env
|
||||||
echo mystate2=mystateval > $GITHUB_STATE
|
echo mystate2=mystateval > $GITHUB_STATE
|
||||||
echo "::save-state name=mystate3::mystateval"
|
echo "::save-state name=mystate3::mystateval"
|
||||||
post: |
|
post: |
|
||||||
|
env
|
||||||
|
[ "$STATE_mystate0" = "mystateval" ]
|
||||||
|
[ "$STATE_mystate1" = "mystateval" ]
|
||||||
[ "$STATE_mystate2" = "mystateval" ]
|
[ "$STATE_mystate2" = "mystateval" ]
|
||||||
[ "$STATE_mystate3" = "mystateval" ]
|
[ "$STATE_mystate3" = "mystateval" ]
|
||||||
# State must be isolated per action instance even when two steps use the same action.
|
|
||||||
test-id-collision-bug:
|
test-id-collision-bug:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: nektos/act-test-actions/script@main
|
||||||
- uses: ./actions/script
|
|
||||||
id: script
|
id: script
|
||||||
with:
|
with:
|
||||||
main: echo mystate=val1 > $GITHUB_STATE
|
pre: |
|
||||||
post: '[ "$STATE_mystate" = "val1" ]'
|
env
|
||||||
- uses: ./actions/script
|
echo mystate0=mystateval > $GITHUB_STATE
|
||||||
|
echo "::save-state name=mystate1::mystateval"
|
||||||
|
main: |
|
||||||
|
env
|
||||||
|
echo mystate2=mystateval > $GITHUB_STATE
|
||||||
|
echo "::save-state name=mystate3::mystateval"
|
||||||
|
post: |
|
||||||
|
env
|
||||||
|
[ "$STATE_mystate0" = "mystateval" ]
|
||||||
|
[ "$STATE_mystate1" = "mystateval" ]
|
||||||
|
[ "$STATE_mystate2" = "mystateval" ]
|
||||||
|
[ "$STATE_mystate3" = "mystateval" ]
|
||||||
|
- uses: nektos/act-test-actions/script@main
|
||||||
id: pre-script
|
id: pre-script
|
||||||
with:
|
with:
|
||||||
main: echo mystate=val2 > $GITHUB_STATE
|
main: |
|
||||||
post: '[ "$STATE_mystate" = "val2" ]'
|
env
|
||||||
|
echo mystate0=mystateerror > $GITHUB_STATE
|
||||||
|
echo "::save-state name=mystate1::mystateerror"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM alpine:3.23
|
FROM alpine:3
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: 'Test'
|
name: 'Test'
|
||||||
description: 'Test'
|
description: 'Test'
|
||||||
runs:
|
runs:
|
||||||
using: 'node24'
|
using: 'node12'
|
||||||
main: 'index.js'
|
main: 'index.js'
|
||||||
|
|||||||
@@ -9,3 +9,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: './actions-environment-and-context-tests/js'
|
- uses: './actions-environment-and-context-tests/js'
|
||||||
- uses: './actions-environment-and-context-tests/docker'
|
- uses: './actions-environment-and-context-tests/docker'
|
||||||
|
- uses: 'nektos/act-test-actions/js@main'
|
||||||
|
- uses: 'nektos/act-test-actions/docker@main'
|
||||||
|
- uses: 'nektos/act-test-actions/docker-file@main'
|
||||||
|
- uses: 'nektos/act-test-actions/docker-relative-context/action@main'
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
FROM ubuntu:24.04
|
FROM ubuntu:18.04
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user