mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-14 11:53:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f6b6d90a | ||
|
|
cdcea87a45 | ||
|
|
3c4bcf3ebf | ||
|
|
e22d3fa263 | ||
|
|
99bc50d538 | ||
|
|
8f72c60afa | ||
|
|
4e7fd1c68a | ||
|
|
bd41a367fe | ||
|
|
c566013db4 | ||
|
|
40e021309a | ||
|
|
d3b3519dea | ||
|
|
6bdcb54828 | ||
|
|
007717956a | ||
|
|
df0370f8bf | ||
|
|
5f0636faad | ||
|
|
4997f33b5f | ||
|
|
2963716953 | ||
|
|
3996d6d032 | ||
|
|
205af7cd01 | ||
|
|
33e6d1d8ff | ||
|
|
56979e6ab8 | ||
|
|
bf99e6a758 | ||
|
|
740a3d4db4 | ||
|
|
822af5029f | ||
|
|
526c46b485 | ||
|
|
355289bc54 | ||
|
|
e583b0706b | ||
|
|
8ad84cd96a | ||
|
|
0a2f28244d | ||
|
|
443b0e336c | ||
|
|
53c4db6a4b | ||
|
|
1073c8bfec | ||
|
|
ff7d9ca8d0 | ||
|
|
984b47c716 | ||
|
|
c749e52bb7 | ||
|
|
f17b6b9fc3 | ||
|
|
c7c4bd600a | ||
|
|
abec931d98 | ||
|
|
270ea41232 | ||
|
|
0b9f251b6a | ||
|
|
273f6b4247 | ||
|
|
47ee45412a | ||
|
|
38b69bb214 | ||
|
|
1c62c0635f | ||
|
|
0e0c54b272 | ||
|
|
d6fbe75721 | ||
|
|
b30204aa94 | ||
|
|
7b5ebe9618 | ||
|
|
4317662a38 | ||
|
|
2208e7ec63 | ||
|
|
fab9714f9a | ||
|
|
10475db58a | ||
|
|
9e738c203c | ||
|
|
6023928876 | ||
|
|
014ce438c1 | ||
|
|
cf7e29c10d | ||
|
|
8a99506fed | ||
|
|
5873b8b054 | ||
|
|
5464d33eef | ||
|
|
3c5f03ff8f | ||
|
|
880e9755d9 | ||
|
|
8d7cf48a6f | ||
|
|
f23605c614 | ||
|
|
00b7fec80f | ||
|
|
dda5841af8 | ||
|
|
32bed52686 | ||
|
|
a7e972d8de | ||
|
|
763b38ece3 | ||
|
|
a1f13cb970 | ||
|
|
1e3ab0c40a | ||
|
|
295eecb9af | ||
|
|
ef6ca957b5 | ||
|
|
8088df52b9 | ||
|
|
3ea7d39690 | ||
|
|
861d351845 | ||
|
|
cce8543d06 | ||
|
|
75643645f0 | ||
|
|
dff63b3ecc | ||
|
|
a5d9fe9651 | ||
|
|
d607f3b342 | ||
|
|
5e59402fb2 | ||
|
|
dfeb463904 | ||
|
|
594c9ade7c | ||
|
|
2a4d56c650 | ||
|
|
a22119cf88 | ||
|
|
b68ecf2580 | ||
|
|
d1434237c2 | ||
|
|
35c65e2b14 | ||
|
|
c45a4e6d32 | ||
|
|
68d9fc45c9 | ||
|
|
b1c873a66b | ||
|
|
1d6e7879c8 | ||
|
|
13dc9386fe | ||
|
|
8e6b3be96a |
No files matched your search
+1
-1
@@ -40,7 +40,7 @@ cpu.out
|
|||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
/act_runner
|
/gitea-runner
|
||||||
/debug
|
/debug
|
||||||
|
|
||||||
/bin
|
/bin
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: pr-title
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-pr-title:
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
- run: make lint-pr-title
|
||||||
|
env:
|
||||||
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
@@ -17,14 +17,14 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
args: release --nightly
|
args: release --nightly
|
||||||
@@ -52,27 +52,32 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
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@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Echo the tag
|
- name: Echo the tag
|
||||||
run: echo "${{ env.DOCKER_ORG }}/act_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@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -82,4 +87,6 @@ jobs:
|
|||||||
linux/arm64
|
linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.DOCKER_ORG }}/act_runner:nightly${{ matrix.variant.tag_suffix }}
|
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.meta.outputs.REPO_VERSION }}
|
||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
@@ -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@v6
|
uses: crazy-max/ghaction-import-gpg@v7
|
||||||
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@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
args: release
|
args: release
|
||||||
@@ -55,33 +55,28 @@ jobs:
|
|||||||
DOCKER_LATEST: latest
|
DOCKER_LATEST: latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
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@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
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: https://github.com/docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.DOCKER_ORG }}/${{ steps.repo_meta.outputs.REPO_NAME }}
|
${{ env.DOCKER_ORG }}/runner
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{major}}.{{minor}}.{{patch}}
|
type=semver,pattern={{major}}.{{minor}}.{{patch}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
@@ -91,7 +86,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@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -101,3 +96,5 @@ 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,20 +1,44 @@
|
|||||||
name: checks
|
name: checks
|
||||||
on:
|
on:
|
||||||
- push
|
push:
|
||||||
- pull_request
|
branches:
|
||||||
|
- 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@v7
|
||||||
- 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
|
||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
/act_runner
|
/gitea-runner
|
||||||
.env
|
.env
|
||||||
|
!/act/runner/testdata/secrets/.env
|
||||||
.runner
|
.runner
|
||||||
coverage.txt
|
coverage.txt
|
||||||
/config.yaml
|
/config.yaml
|
||||||
@@ -10,4 +11,4 @@ coverage.txt
|
|||||||
.vscode
|
.vscode
|
||||||
__debug_bin
|
__debug_bin
|
||||||
# gorelease binary folder
|
# gorelease binary folder
|
||||||
dist
|
/dist
|
||||||
+1
-1
@@ -114,7 +114,7 @@ formatters:
|
|||||||
custom-order: true
|
custom-order: true
|
||||||
sections:
|
sections:
|
||||||
- standard
|
- standard
|
||||||
- prefix(gitea.com/gitea/act_runner)
|
- prefix(gitea.com/gitea/runner)
|
||||||
- blank
|
- blank
|
||||||
- default
|
- default
|
||||||
gofumpt:
|
gofumpt:
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,7 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
|
project_name: gitea-runner
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
@@ -63,7 +65,7 @@ builds:
|
|||||||
flags:
|
flags:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X gitea.com/gitea/act_runner/internal/pkg/ver.version={{ .Summary }}
|
- -s -w -X gitea.com/gitea/runner/internal/pkg/ver.version={{ .Summary }}
|
||||||
binary: >-
|
binary: >-
|
||||||
{{ .ProjectName }}-
|
{{ .ProjectName }}-
|
||||||
{{- .Version }}-
|
{{- .Version }}-
|
||||||
@@ -86,7 +88,7 @@ blobs:
|
|||||||
provider: s3
|
provider: s3
|
||||||
bucket: "{{ .Env.S3_BUCKET }}"
|
bucket: "{{ .Env.S3_BUCKET }}"
|
||||||
region: "{{ .Env.S3_REGION }}"
|
region: "{{ .Env.S3_REGION }}"
|
||||||
directory: "act_runner/{{.Version}}"
|
directory: "gitea-runner/{{.Version}}"
|
||||||
extra_files:
|
extra_files:
|
||||||
- glob: ./**.xz
|
- glob: ./**.xz
|
||||||
- glob: ./**.sha256
|
- glob: ./**.sha256
|
||||||
|
|||||||
+25
-9
@@ -1,7 +1,7 @@
|
|||||||
### BUILDER STAGE
|
### BUILDER STAGE
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM golang:1.26-alpine AS builder
|
FROM golang:1.26-alpine3.23 AS builder
|
||||||
|
|
||||||
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
# 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
|
||||||
@@ -9,19 +9,24 @@ RUN apk add --no-cache make git
|
|||||||
ARG GOPROXY
|
ARG GOPROXY
|
||||||
ENV GOPROXY=${GOPROXY:-}
|
ENV GOPROXY=${GOPROXY:-}
|
||||||
|
|
||||||
COPY . /opt/src/act_runner
|
COPY . /opt/src/runner
|
||||||
WORKDIR /opt/src/act_runner
|
WORKDIR /opt/src/runner
|
||||||
|
|
||||||
RUN make clean && make build
|
RUN make clean && make build
|
||||||
|
|
||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:28-dind AS dind
|
FROM docker:29.6.0-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/act_runner/act_runner /usr/local/bin/act_runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-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
|
||||||
|
|
||||||
@@ -32,12 +37,17 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:28-dind-rootless AS dind-rootless
|
FROM docker:29.6.0-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/act_runner/act_runner /usr/local/bin/act_runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-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
|
||||||
|
|
||||||
@@ -53,10 +63,16 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### BASIC VARIANT
|
### BASIC VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM alpine AS basic
|
FROM alpine:3.24 AS basic
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||||
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
RUN apk add --no-cache tini bash git tzdata
|
RUN apk add --no-cache tini bash git tzdata
|
||||||
|
|
||||||
COPY --from=builder /opt/src/act_runner/act_runner /usr/local/bin/act_runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
DIST := dist
|
DIST := dist
|
||||||
EXECUTABLE := act_runner
|
EXECUTABLE := gitea-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
|
||||||
@@ -13,13 +13,13 @@ DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
|||||||
WINDOWS_ARCHS ?= windows/amd64
|
WINDOWS_ARCHS ?= windows/amd64
|
||||||
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
||||||
|
|
||||||
DOCKER_IMAGE ?= gitea/act_runner
|
DOCKER_IMAGE ?= gitea/runner
|
||||||
DOCKER_TAG ?= nightly
|
DOCKER_TAG ?= nightly
|
||||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]1.4
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]2.2
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
||||||
|
|
||||||
STATIC ?=
|
STATIC ?=
|
||||||
EXTLDFLAGS ?=
|
EXTLDFLAGS ?=
|
||||||
@@ -38,12 +38,15 @@ endif
|
|||||||
ifeq ($(OS), Windows_NT)
|
ifeq ($(OS), Windows_NT)
|
||||||
GOFLAGS := -v -buildmode=exe
|
GOFLAGS := -v -buildmode=exe
|
||||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||||
|
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||||
else ifeq ($(OS), Windows)
|
else ifeq ($(OS), Windows)
|
||||||
GOFLAGS := -v -buildmode=exe
|
GOFLAGS := -v -buildmode=exe
|
||||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||||
|
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||||
else
|
else
|
||||||
GOFLAGS := -v
|
GOFLAGS := -v
|
||||||
EXECUTABLE ?= $(EXECUTABLE)
|
EXECUTABLE ?= $(EXECUTABLE)
|
||||||
|
GO_ENV_WINDOWS := GOOS=windows
|
||||||
endif
|
endif
|
||||||
|
|
||||||
STORED_VERSION_FILE := VERSION
|
STORED_VERSION_FILE := VERSION
|
||||||
@@ -67,7 +70,7 @@ else
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
TAGS ?=
|
TAGS ?=
|
||||||
LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
|
LDFLAGS ?= -X "gitea.com/gitea/runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
|
||||||
|
|
||||||
.PHONY: all
|
.PHONY: all
|
||||||
all: build
|
all: build
|
||||||
@@ -86,7 +89,7 @@ go-check:
|
|||||||
$(eval MIN_GO_VERSION := $(shell printf "%03d%03d" $(shell echo '$(MIN_GO_VERSION_STR)' | tr '.' ' ')))
|
$(eval MIN_GO_VERSION := $(shell printf "%03d%03d" $(shell echo '$(MIN_GO_VERSION_STR)' | tr '.' ' ')))
|
||||||
$(eval GO_VERSION := $(shell printf "%03d%03d" $(shell $(GO) version | grep -Eo '[0-9]+\.[0-9]+' | tr '.' ' ');))
|
$(eval GO_VERSION := $(shell printf "%03d%03d" $(shell $(GO) version | grep -Eo '[0-9]+\.[0-9]+' | tr '.' ' ');))
|
||||||
@if [ "$(GO_VERSION)" -lt "$(MIN_GO_VERSION)" ]; then \
|
@if [ "$(GO_VERSION)" -lt "$(MIN_GO_VERSION)" ]; then \
|
||||||
echo "Act Runner requires Go $(MIN_GO_VERSION_STR) or greater to build. You can get it at https://go.dev/dl/"; \
|
echo "Gitea Runner requires Go $(MIN_GO_VERSION_STR) or greater to build. You can get it at https://go.dev/dl/"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -108,16 +111,25 @@ deps-tools: ## install tool dependencies
|
|||||||
wait
|
wait
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go ## lint everything
|
lint: lint-go lint-go-windows ## lint everything
|
||||||
|
|
||||||
.PHONY: lint-go
|
.PHONY: lint-go
|
||||||
lint-go: ## lint go files
|
lint-go: ## lint go files
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
||||||
|
|
||||||
|
.PHONY: lint-go-windows
|
||||||
|
lint-go-windows: ## lint Windows go files
|
||||||
|
$(GO) install $(GOLANGCI_LINT_PACKAGE)
|
||||||
|
$(GO_ENV_WINDOWS) golangci-lint run
|
||||||
|
|
||||||
.PHONY: lint-go-fix
|
.PHONY: lint-go-fix
|
||||||
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
|
||||||
@@ -136,15 +148,19 @@ tidy-check: tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: fmt-check security-check ## test everything
|
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
||||||
@$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
@$(GO) test -race -timeout 20m -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 act_runner binary via `go install`
|
install: $(GOFILES) ## install the runner binary via `go install`
|
||||||
$(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)'
|
$(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)'
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: go-check $(EXECUTABLE) ## build the act_runner binary
|
build: go-check $(EXECUTABLE) ## build the runner binary
|
||||||
|
|
||||||
$(EXECUTABLE): $(GOFILES)
|
$(EXECUTABLE): $(GOFILES)
|
||||||
$(GO) build -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
$(GO) build -v -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# act runner
|
# Gitea Runner
|
||||||
|
|
||||||
Act runner is a runner for Gitea.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -10,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/act_runner/) and download the right version for your platform.
|
Visit [here](https://dl.gitea.com/gitea-runner/) and download the right version for your platform.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
@@ -36,7 +34,7 @@ ENABLED=true
|
|||||||
### Register
|
### Register
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./act_runner register
|
./gitea-runner register
|
||||||
```
|
```
|
||||||
|
|
||||||
And you will be asked to input:
|
And you will be asked to input:
|
||||||
@@ -68,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
|
||||||
./act_runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
|
./gitea-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.
|
||||||
@@ -76,32 +74,150 @@ If the registry succeed, it will run immediately. Next time, you could run the r
|
|||||||
### Run
|
### Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./act_runner daemon
|
./gitea-runner daemon
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run with docker
|
### Run with docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/act_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)).
|
||||||
|
|
||||||
|
### Image flavours
|
||||||
|
|
||||||
|
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
|
||||||
|
|
||||||
|
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
|
||||||
|
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
|
||||||
|
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
|
||||||
|
|
||||||
|
#### `latest` — basic
|
||||||
|
|
||||||
|
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
|
||||||
|
|
||||||
|
#### `latest-dind` — Docker-in-Docker
|
||||||
|
|
||||||
|
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||||
|
--name my_runner gitea/runner:latest-dind
|
||||||
|
```
|
||||||
|
|
||||||
|
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged.
|
||||||
|
|
||||||
|
#### `latest-dind-rootless` — rootless Docker-in-Docker
|
||||||
|
|
||||||
|
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
|
||||||
|
|
||||||
|
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
You can also configure the runner with a configuration file.
|
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
||||||
The configuration file is a YAML file, you can generate a sample configuration file with `./act_runner generate-config`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./act_runner generate-config > config.yaml
|
./gitea-runner generate-config > config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
You can specify the configuration file path with `-c`/`--config` argument.
|
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./act_runner -c config.yaml register # register with config file
|
./gitea-runner -c config.yaml register
|
||||||
./act_runner -c config.yaml daemon # run with config file
|
./gitea-runner -c config.yaml daemon
|
||||||
|
./gitea-runner -c config.yaml cache-server
|
||||||
```
|
```
|
||||||
|
|
||||||
You can read the latest version of the configuration file online at [config.example.yaml](internal/pkg/config/config.example.yaml).
|
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `generate-config` prints).
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
#### Caching (`actions/cache`)
|
||||||
|
|
||||||
|
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
||||||
|
|
||||||
|
**Shared cache across multiple runners**
|
||||||
|
|
||||||
|
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||||
|
|
||||||
|
1. Create a config file for the cache server host:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
dir: /data/actcache
|
||||||
|
port: 8088
|
||||||
|
external_secret: "replace-with-a-strong-random-secret"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitea-runner -c cache-server-config.yaml cache-server
|
||||||
|
```
|
||||||
|
|
||||||
|
3. On every runner:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
external_server: "http://<cache-server-host>:8088/"
|
||||||
|
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
||||||
|
|
||||||
|
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
||||||
|
|
||||||
|
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
|
||||||
|
|
||||||
|
#### Official Docker image
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
#### Post-task script (`runner.post_task_script`)
|
||||||
|
|
||||||
|
Optional host script that runs **after** each task's built-in cleanup (post-steps, container teardown, bind-workdir removal). Use it for extra machine housekeeping — Docker pruning, disk cleanup, and similar.
|
||||||
|
|
||||||
|
**While the script runs, the runner stops task heartbeats and stays offline from Gitea's perspective until the script exits (or hits `runner.post_task_script_timeout`, default `5m`).** A script that blocks without exiting keeps the runner from taking new work for up to that timeout. Script output goes to the runner log, not the job log; a non-zero exit is warned but does not change the job result.
|
||||||
|
|
||||||
|
On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not supported yet** as the configured path — wrap commands in a `.cmd` file instead.
|
||||||
|
|
||||||
|
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
||||||
|
|
||||||
### Example Deployments
|
### Example Deployments
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
@@ -325,10 +325,6 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
|
|||||||
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
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()
|
||||||
@@ -371,8 +367,6 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
|
|||||||
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
|
cache.Repo = cred.Repo
|
||||||
@@ -437,6 +431,7 @@ 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)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -338,6 +339,54 @@ func TestHandler(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("upload write failure returns only error", func(t *testing.T) {
|
||||||
|
key := strings.ToLower(t.Name())
|
||||||
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
|
var id uint64
|
||||||
|
{
|
||||||
|
body, err := json.Marshal(&Request{
|
||||||
|
Key: key,
|
||||||
|
Version: version,
|
||||||
|
Size: 100,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
|
||||||
|
got := struct {
|
||||||
|
CacheID uint64 `json:"cacheId"`
|
||||||
|
}{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||||
|
id = got.CacheID
|
||||||
|
}
|
||||||
|
|
||||||
|
storageFile := filepath.Join(dir, "not-a-directory")
|
||||||
|
require.NoError(t, os.WriteFile(storageFile, []byte("blocked"), 0o600))
|
||||||
|
originalStorage := handler.storage
|
||||||
|
handler.storage = &Storage{rootDir: storageFile}
|
||||||
|
defer func() {
|
||||||
|
handler.storage = originalStorage
|
||||||
|
}()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPatch,
|
||||||
|
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(make([]byte, 100)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
req.Header.Set("Content-Range", "bytes 0-99/*")
|
||||||
|
resp, err := testClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, 500, resp.StatusCode)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var got map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(body, &got))
|
||||||
|
assert.NotEmpty(t, got["error"])
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("commit early", func(t *testing.T) {
|
t.Run("commit early", func(t *testing.T) {
|
||||||
key := strings.ToLower(t.Name())
|
key := strings.ToLower(t.Name())
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
@@ -459,17 +508,20 @@ func TestHandler(t *testing.T) {
|
|||||||
assert.Equal(t, contents[except], content)
|
assert.Equal(t, contents[except], content)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("case insensitive", func(t *testing.T) {
|
t.Run("case preserved", func(t *testing.T) {
|
||||||
|
// Some actions (e.g. actions/setup-go, actions/setup-node) build cache keys that contain mixed-case fragments such as RUNNER_OS=Linux,
|
||||||
|
// then compare the cacheKey returned by the cache server to their original key with case-sensitive equality to decide whether the
|
||||||
|
// cache was a complete hit. The server must therefore preserve the original key case.
|
||||||
|
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
key := strings.ToLower(t.Name())
|
key := strings.ToLower(t.Name()) + "_ABC"
|
||||||
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+"_ABC", version, content)
|
uploadCacheNormally(t, base, key, version, content)
|
||||||
|
|
||||||
{
|
{
|
||||||
reqKey := key + "_aBc"
|
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
||||||
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
require.Equal(t, 200, resp.StatusCode)
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
@@ -480,7 +532,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, "hit", got.Result)
|
assert.Equal(t, "hit", got.Result)
|
||||||
assert.Equal(t, key+"_abc", got.CacheKey)
|
assert.Equal(t, key, got.CacheKey)
|
||||||
|
assert.NotEqual(t, strings.ToLower(key), got.CacheKey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -643,7 +696,7 @@ 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, strings.ToLower(key), got.CacheKey)
|
assert.Equal(t, key, got.CacheKey)
|
||||||
archiveLocation = got.ArchiveLocation
|
archiveLocation = got.ArchiveLocation
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -969,7 +1022,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHandler_SecretPersistsAcrossRestarts is the property that lets
|
// TestHandler_SecretPersistsAcrossRestarts is the property that lets
|
||||||
// act_runner cache-server be pointed at via cfg.Cache.ExternalServer: a
|
// gitea-runner cache-server be pointed at via cfg.Cache.ExternalServer: a
|
||||||
// restart must not invalidate signed URLs the handler has already issued
|
// restart must not invalidate signed URLs the handler has already issued
|
||||||
// (within their expiry window).
|
// (within their expiry window).
|
||||||
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
|
||||||
|
|||||||
-30
@@ -1,30 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
)
|
)
|
||||||
|
|||||||
+120
-75
@@ -5,24 +5,25 @@
|
|||||||
package artifacts
|
package artifacts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"bytes"
|
||||||
|
"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/act_runner/act/model"
|
|
||||||
"gitea.com/gitea/act_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 {
|
||||||
@@ -234,89 +235,133 @@ func TestDownloadArtifactFile(t *testing.T) {
|
|||||||
assert.Equal("content", string(data))
|
assert.Equal("content", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
type TestJobFileInfo struct {
|
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
|
||||||
workdir string
|
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
|
||||||
workflowPath string
|
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
|
||||||
eventName string
|
// and reachable everywhere, including when the CI job is itself a container.
|
||||||
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) {
|
||||||
if testing.Short() {
|
artifactPath := t.TempDir()
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
t.Run("upload-and-download", func(t *testing.T) {
|
||||||
|
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n"
|
||||||
|
|
||||||
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort)
|
status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
defer cancel()
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
var prep FileContainerResourceURL
|
||||||
|
require.NoError(t, json.Unmarshal(data, &prep))
|
||||||
|
require.Equal(t, baseURL+"/upload/"+runID, prep.FileContainerResourceURL)
|
||||||
|
|
||||||
platforms := map[string]string{
|
status, data = request(t, http.MethodPut, prep.FileContainerResourceURL+"?itemPath="+url.QueryEscape(item), strings.NewReader(content), nil)
|
||||||
"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
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
}
|
var msg ResponseMessage
|
||||||
|
require.NoError(t, json.Unmarshal(data, &msg))
|
||||||
|
require.Equal(t, "success", msg.Message)
|
||||||
|
|
||||||
tables := []TestJobFileInfo{
|
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
{"testdata", "upload-and-download", "push", "", platforms, ""},
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
|
|
||||||
}
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
|
|
||||||
for _, table := range tables {
|
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
runTestJobFile(ctx, t, table)
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
}
|
var list NamedFileContainerResourceURLResponse
|
||||||
}
|
require.NoError(t, json.Unmarshal(data, &list))
|
||||||
|
require.Equal(t, 1, list.Count)
|
||||||
|
require.Equal(t, "my-artifact", list.Value[0].Name)
|
||||||
|
|
||||||
func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
|
status, data = request(t, http.MethodGet, list.Value[0].FileContainerResourceURL+"?itemPath=my-artifact", nil, nil)
|
||||||
t.Run(tjfi.workflowPath, func(t *testing.T) {
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
fmt.Printf("::group::%s\n", tjfi.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
|
var items ContainerItemResponse
|
||||||
|
require.NoError(t, json.Unmarshal(data, &items))
|
||||||
|
require.Len(t, items.Value, 1)
|
||||||
|
require.Equal(t, "file", items.Value[0].ItemType)
|
||||||
|
require.Equal(t, "my-artifact/data.txt", items.Value[0].Path)
|
||||||
|
|
||||||
if err := os.RemoveAll(artifactsPath); err != nil {
|
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
||||||
panic(err)
|
require.Equal(t, http.StatusOK, status)
|
||||||
}
|
require.Equal(t, content, string(data))
|
||||||
|
|
||||||
workdir, err := filepath.Abs(tjfi.workdir)
|
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt"))
|
||||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
require.NoError(t, err)
|
||||||
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
|
require.Equal(t, content, string(stored))
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
runner, err := runner.New(runnerConfig)
|
t.Run("gzip-roundtrip", func(t *testing.T) {
|
||||||
assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
||||||
|
|
||||||
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
var buf bytes.Buffer
|
||||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
gz := gzip.NewWriter(&buf)
|
||||||
|
_, err := gz.Write([]byte(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, gz.Close())
|
||||||
|
|
||||||
plan, err := planner.PlanEvent(tjfi.eventName)
|
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape(item),
|
||||||
if err == nil {
|
&buf, http.Header{"Content-Encoding": []string{"gzip"}})
|
||||||
err = runner.NewPlanExecutor(plan)(ctx)
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
if tjfi.errorMessage == "" {
|
|
||||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
} else {
|
|
||||||
assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
assert.Nil(t, plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
// stored compressed, with the server's gzip marker suffix
|
||||||
|
_, err = os.Stat(filepath.Join(artifactPath, runID, "logs", "app.log.gz__"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodGet, baseURL+"/download/"+runID+"?itemPath=logs", nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
var items ContainerItemResponse
|
||||||
|
require.NoError(t, json.Unmarshal(data, &items))
|
||||||
|
require.Len(t, items.Value, 1)
|
||||||
|
require.Equal(t, "logs/app.log", items.Value[0].Path)
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.Equal(t, content, string(data))
|
||||||
|
})
|
||||||
|
|
||||||
|
// GHSL-2023-004: an itemPath that climbs out of the run directory must be neutralised so the
|
||||||
|
// blob cannot be written outside the artifact root.
|
||||||
|
t.Run("GHSL-2023-004", func(t *testing.T) {
|
||||||
|
const runID, content = "3", "contained\n"
|
||||||
|
|
||||||
|
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape("../../escape.txt"),
|
||||||
|
strings.NewReader(content), nil)
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
|
||||||
|
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "escape.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, content, string(stored))
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(filepath.Dir(artifactPath), "escape.txt"))
|
||||||
|
require.True(t, os.IsNotExist(err), "upload escaped the artifact root")
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodGet, baseURL+"/artifact/"+runID+"/escape.txt", nil, nil)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
require.Equal(t, content, string(data))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
|
|
||||||
name: "GHSL-2023-0004"
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-artifacts:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo "hello world" > test.txt
|
|
||||||
- name: curl upload
|
|
||||||
run: curl --silent --show-error --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt
|
|
||||||
- uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: my-artifact
|
|
||||||
path: test-artifacts
|
|
||||||
- name: 'Verify Artifact #1'
|
|
||||||
run: |
|
|
||||||
file="test-artifacts/secret.txt"
|
|
||||||
if [ ! -f $file ] ; then
|
|
||||||
echo "Expected file does not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$(cat $file)" != "hello world" ] ; then
|
|
||||||
echo "File contents of downloaded artifact are incorrect"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- name: Verify download should work by clean extra dots
|
|
||||||
run: curl --silent --show-error --fail --path-as-is -o out.txt ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt
|
|
||||||
- name: 'Verify download content'
|
|
||||||
run: |
|
|
||||||
file="out.txt"
|
|
||||||
if [ ! -f $file ] ; then
|
|
||||||
echo "Expected file does not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$(cat $file)" != "hello world" ] ; then
|
|
||||||
echo "File contents of downloaded artifact are incorrect"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
|
|
||||||
name: "Test that artifact uploads and downloads succeed"
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-artifacts:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: mkdir -p path/to/artifact
|
|
||||||
- run: echo hello > path/to/artifact/world.txt
|
|
||||||
- uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: my-artifact
|
|
||||||
path: path/to/artifact/world.txt
|
|
||||||
|
|
||||||
- run: rm -rf path
|
|
||||||
|
|
||||||
- uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: my-artifact
|
|
||||||
- name: Display structure of downloaded files
|
|
||||||
run: ls -la
|
|
||||||
|
|
||||||
# Test end-to-end by uploading two artifacts and then downloading them
|
|
||||||
- name: Create artifact files
|
|
||||||
run: |
|
|
||||||
mkdir -p path/to/dir-1
|
|
||||||
mkdir -p path/to/dir-2
|
|
||||||
mkdir -p path/to/dir-3
|
|
||||||
mkdir -p path/to/dir-5
|
|
||||||
mkdir -p path/to/dir-6
|
|
||||||
mkdir -p path/to/dir-7
|
|
||||||
echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt
|
|
||||||
echo "Hello world from file #2" > path/to/dir-2/file2.txt
|
|
||||||
echo "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" > path/to/dir-3/gzip.txt
|
|
||||||
dd if=/dev/random of=path/to/dir-5/file5.rnd bs=1024 count=1024
|
|
||||||
dd if=/dev/random of=path/to/dir-6/file6.rnd bs=1024 count=$((10*1024))
|
|
||||||
dd if=/dev/random of=path/to/dir-7/file7.rnd bs=1024 count=$((10*1024))
|
|
||||||
|
|
||||||
# Upload a single file artifact
|
|
||||||
- name: 'Upload artifact #1'
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Artifact-A'
|
|
||||||
path: path/to/dir-1/file1.txt
|
|
||||||
|
|
||||||
# Upload using a wildcard pattern, name should default to 'artifact' if not provided
|
|
||||||
- name: 'Upload artifact #2'
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
path: path/**/dir*/
|
|
||||||
|
|
||||||
# Upload a directory that contains a file that will be uploaded with GZip
|
|
||||||
- name: 'Upload artifact #3'
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'GZip-Artifact'
|
|
||||||
path: path/to/dir-3/
|
|
||||||
|
|
||||||
# Upload a directory that contains a file that will be uploaded with GZip
|
|
||||||
- name: 'Upload artifact #4'
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Multi-Path-Artifact'
|
|
||||||
path: |
|
|
||||||
path/to/dir-1/*
|
|
||||||
path/to/dir-[23]/*
|
|
||||||
!path/to/dir-3/*.txt
|
|
||||||
|
|
||||||
# Upload a mid-size file artifact
|
|
||||||
- name: 'Upload artifact #5'
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Mid-Size-Artifact'
|
|
||||||
path: path/to/dir-5/file5.rnd
|
|
||||||
|
|
||||||
# Upload a big file artifact
|
|
||||||
- name: 'Upload artifact #6'
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Big-Artifact'
|
|
||||||
path: path/to/dir-6/file6.rnd
|
|
||||||
|
|
||||||
# Upload a big file artifact twice
|
|
||||||
- name: 'Upload artifact #7 (First)'
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Big-Uploaded-Twice'
|
|
||||||
path: path/to/dir-7/file7.rnd
|
|
||||||
|
|
||||||
# Upload a big file artifact twice
|
|
||||||
- name: 'Upload artifact #7 (Second)'
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Big-Uploaded-Twice'
|
|
||||||
path: path/to/dir-7/file7.rnd
|
|
||||||
|
|
||||||
# Verify artifacts. Switch to download-artifact@v2 once it's out of preview
|
|
||||||
|
|
||||||
# Download Artifact #1 and verify the correctness of the content
|
|
||||||
- name: 'Download artifact #1'
|
|
||||||
uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Artifact-A'
|
|
||||||
path: some/new/path
|
|
||||||
|
|
||||||
- name: 'Verify Artifact #1'
|
|
||||||
run: |
|
|
||||||
file="some/new/path/file1.txt"
|
|
||||||
if [ ! -f $file ] ; then
|
|
||||||
echo "Expected file does not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$(cat $file)" != "Lorem ipsum dolor sit amet" ] ; then
|
|
||||||
echo "File contents of downloaded artifact are incorrect"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download Artifact #2 and verify the correctness of the content
|
|
||||||
- name: 'Download artifact #2'
|
|
||||||
uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'artifact'
|
|
||||||
path: some/other/path
|
|
||||||
|
|
||||||
- name: 'Verify Artifact #2'
|
|
||||||
run: |
|
|
||||||
file1="some/other/path/to/dir-1/file1.txt"
|
|
||||||
file2="some/other/path/to/dir-2/file2.txt"
|
|
||||||
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
|
||||||
echo "Expected files do not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
|
||||||
echo "File contents of downloaded artifacts are incorrect"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download Artifact #3 and verify the correctness of the content
|
|
||||||
- name: 'Download artifact #3'
|
|
||||||
uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'GZip-Artifact'
|
|
||||||
path: gzip/artifact/path
|
|
||||||
|
|
||||||
# Because a directory was used as input during the upload the parent directories, path/to/dir-3/, should not be included in the uploaded artifact
|
|
||||||
- name: 'Verify Artifact #3'
|
|
||||||
run: |
|
|
||||||
gzipFile="gzip/artifact/path/gzip.txt"
|
|
||||||
if [ ! -f $gzipFile ] ; then
|
|
||||||
echo "Expected file do not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$(cat $gzipFile)" != "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" ] ; then
|
|
||||||
echo "File contents of downloaded artifact is incorrect"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 'Download artifact #4'
|
|
||||||
uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Multi-Path-Artifact'
|
|
||||||
path: multi/artifact
|
|
||||||
|
|
||||||
- name: 'Verify Artifact #4'
|
|
||||||
run: |
|
|
||||||
file1="multi/artifact/dir-1/file1.txt"
|
|
||||||
file2="multi/artifact/dir-2/file2.txt"
|
|
||||||
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
|
||||||
echo "Expected files do not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
|
||||||
echo "File contents of downloaded artifacts are incorrect"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 'Download artifact #5'
|
|
||||||
uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Mid-Size-Artifact'
|
|
||||||
path: mid-size/artifact/path
|
|
||||||
|
|
||||||
- name: 'Verify Artifact #5'
|
|
||||||
run: |
|
|
||||||
file="mid-size/artifact/path/file5.rnd"
|
|
||||||
if [ ! -f $file ] ; then
|
|
||||||
echo "Expected file does not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! diff $file path/to/dir-5/file5.rnd ; then
|
|
||||||
echo "File contents of downloaded artifact are incorrect"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 'Download artifact #6'
|
|
||||||
uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Big-Artifact'
|
|
||||||
path: big/artifact/path
|
|
||||||
|
|
||||||
- name: 'Verify Artifact #6'
|
|
||||||
run: |
|
|
||||||
file="big/artifact/path/file6.rnd"
|
|
||||||
if [ ! -f $file ] ; then
|
|
||||||
echo "Expected file does not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! diff $file path/to/dir-6/file6.rnd ; then
|
|
||||||
echo "File contents of downloaded artifact are incorrect"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 'Download artifact #7'
|
|
||||||
uses: actions/download-artifact@v2
|
|
||||||
with:
|
|
||||||
name: 'Big-Uploaded-Twice'
|
|
||||||
path: big-uploaded-twice/artifact/path
|
|
||||||
|
|
||||||
- name: 'Verify Artifact #7'
|
|
||||||
run: |
|
|
||||||
file="big-uploaded-twice/artifact/path/file7.rnd"
|
|
||||||
if [ ! -f $file ] ; then
|
|
||||||
echo "Expected file does not exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! diff $file path/to/dir-7/file7.rnd ; then
|
|
||||||
echo "File contents of downloaded artifact are incorrect"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
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)
|
||||||
@@ -46,7 +48,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 := len(n) - 1; j >= 0; j-- {
|
for j := range slices.Backward(n) {
|
||||||
n[j]++
|
n[j]++
|
||||||
if n[j] < len(a[j]) {
|
if n[j] < len(a[j]) {
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Style is a specific style
|
|
||||||
type Style int
|
|
||||||
|
|
||||||
// Styles
|
|
||||||
const (
|
|
||||||
StyleDoubleLine = iota
|
|
||||||
StyleSingleLine
|
|
||||||
StyleDashedLine
|
|
||||||
StyleNoLine
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewPen creates a new pen
|
|
||||||
func NewPen(style Style, color int) *Pen {
|
|
||||||
bgcolor := 49
|
|
||||||
if os.Getenv("CLICOLOR") == "0" {
|
|
||||||
color = 0
|
|
||||||
bgcolor = 0
|
|
||||||
}
|
|
||||||
return &Pen{
|
|
||||||
style: style,
|
|
||||||
color: color,
|
|
||||||
bgcolor: bgcolor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type styleDef struct {
|
|
||||||
cornerTL string
|
|
||||||
cornerTR string
|
|
||||||
cornerBL string
|
|
||||||
cornerBR string
|
|
||||||
lineH string
|
|
||||||
lineV string
|
|
||||||
}
|
|
||||||
|
|
||||||
var styleDefs = []styleDef{
|
|
||||||
{"\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"},
|
|
||||||
{"\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"},
|
|
||||||
{"\u250c", "\u2510", "\u2514", "\u2518", "\u254c", "\u254e"},
|
|
||||||
{" ", " ", " ", " ", " ", " "},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pen struct
|
|
||||||
type Pen struct {
|
|
||||||
style Style
|
|
||||||
color int
|
|
||||||
bgcolor int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drawing struct
|
|
||||||
type Drawing struct {
|
|
||||||
buf *strings.Builder
|
|
||||||
width int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Pen) drawTopBars(buf io.Writer, labels ...string) {
|
|
||||||
style := styleDefs[p.style]
|
|
||||||
for _, label := range labels {
|
|
||||||
bar := strings.Repeat(style.lineH, len(label)+2)
|
|
||||||
fmt.Fprintf(buf, " ")
|
|
||||||
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
|
||||||
fmt.Fprintf(buf, "%s%s%s", style.cornerTL, bar, style.cornerTR)
|
|
||||||
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(buf, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Pen) drawBottomBars(buf io.Writer, labels ...string) {
|
|
||||||
style := styleDefs[p.style]
|
|
||||||
for _, label := range labels {
|
|
||||||
bar := strings.Repeat(style.lineH, len(label)+2)
|
|
||||||
fmt.Fprintf(buf, " ")
|
|
||||||
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
|
||||||
fmt.Fprintf(buf, "%s%s%s", style.cornerBL, bar, style.cornerBR)
|
|
||||||
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(buf, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Pen) drawLabels(buf io.Writer, labels ...string) {
|
|
||||||
style := styleDefs[p.style]
|
|
||||||
for _, label := range labels {
|
|
||||||
fmt.Fprintf(buf, " ")
|
|
||||||
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
|
|
||||||
fmt.Fprintf(buf, "%s %s %s", style.lineV, label, style.lineV)
|
|
||||||
fmt.Fprintf(buf, "\x1b[%dm", 0)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(buf, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// DrawArrow between boxes
|
|
||||||
func (p *Pen) DrawArrow() *Drawing {
|
|
||||||
drawing := &Drawing{
|
|
||||||
buf: new(strings.Builder),
|
|
||||||
width: 1,
|
|
||||||
}
|
|
||||||
fmt.Fprintf(drawing.buf, "\x1b[%dm", p.color)
|
|
||||||
fmt.Fprintf(drawing.buf, "\u2b07")
|
|
||||||
fmt.Fprintf(drawing.buf, "\x1b[%dm", 0)
|
|
||||||
return drawing
|
|
||||||
}
|
|
||||||
|
|
||||||
// DrawBoxes to draw boxes
|
|
||||||
func (p *Pen) DrawBoxes(labels ...string) *Drawing {
|
|
||||||
width := 0
|
|
||||||
for _, l := range labels {
|
|
||||||
width += len(l) + 2 + 2 + 1
|
|
||||||
}
|
|
||||||
drawing := &Drawing{
|
|
||||||
buf: new(strings.Builder),
|
|
||||||
width: width,
|
|
||||||
}
|
|
||||||
p.drawTopBars(drawing.buf, labels...)
|
|
||||||
p.drawLabels(drawing.buf, labels...)
|
|
||||||
p.drawBottomBars(drawing.buf, labels...)
|
|
||||||
|
|
||||||
return drawing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw to writer
|
|
||||||
func (d *Drawing) Draw(writer io.Writer, centerOnWidth int) {
|
|
||||||
padSize := max((centerOnWidth-d.GetWidth())/2, 0)
|
|
||||||
for l := range strings.SplitSeq(d.buf.String(), "\n") {
|
|
||||||
if len(l) > 0 {
|
|
||||||
padding := strings.Repeat(" ", padSize)
|
|
||||||
fmt.Fprintf(writer, "%s%s\n", padding, l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWidth of drawing
|
|
||||||
func (d *Drawing) GetWidth() int {
|
|
||||||
return d.width
|
|
||||||
}
|
|
||||||
+8
-26
@@ -12,24 +12,6 @@ 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
|
||||||
|
|
||||||
@@ -97,6 +79,12 @@ 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))
|
||||||
@@ -117,7 +105,7 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
|||||||
log.Debugf("Worker %d executing task %d", workerID, taskCount)
|
log.Debugf("Worker %d executing task %d", workerID, taskCount)
|
||||||
// Recover from panics in executors to avoid crashing the worker
|
// Recover from panics in executors to avoid crashing the worker
|
||||||
// goroutine which would leave the runner process hung.
|
// goroutine which would leave the runner process hung.
|
||||||
// https://gitea.com/gitea/act_runner/issues/371
|
// https://gitea.com/gitea/runner/issues/371
|
||||||
errs <- func() (err error) {
|
errs <- func() (err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
@@ -156,15 +144,9 @@ 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 {
|
||||||
err := e(ctx)
|
if err := e(ctx); err != nil {
|
||||||
if err != nil {
|
|
||||||
switch err.(type) {
|
|
||||||
case Warning:
|
|
||||||
Logger(ctx).Warning(err.Error())
|
|
||||||
default:
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,68 +170,6 @@ 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,6 +12,7 @@ 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) {
|
||||||
@@ -119,6 +120,19 @@ func TestNewParallelExecutor(t *testing.T) {
|
|||||||
assert.NoError(errSingle)
|
assert.NoError(errSingle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CopyFile copy file
|
|
||||||
func CopyFile(source, dest string) (err error) {
|
|
||||||
sourcefile, err := os.Open(source)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer sourcefile.Close()
|
|
||||||
|
|
||||||
destfile, err := os.Create(dest)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer destfile.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(destfile, sourcefile)
|
|
||||||
if err == nil {
|
|
||||||
sourceinfo, err := os.Stat(source)
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Chmod(dest, sourceinfo.Mode())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CopyDir recursive copy of directory
|
|
||||||
func CopyDir(source, dest string) (err error) {
|
|
||||||
// get properties of source dir
|
|
||||||
sourceinfo, err := os.Stat(source)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// create dest dir
|
|
||||||
|
|
||||||
err = os.MkdirAll(dest, sourceinfo.Mode())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objects, err := os.ReadDir(source)
|
|
||||||
|
|
||||||
for _, obj := range objects {
|
|
||||||
sourcefilepointer := source + "/" + obj.Name()
|
|
||||||
|
|
||||||
destinationfilepointer := dest + "/" + obj.Name()
|
|
||||||
|
|
||||||
if obj.IsDir() {
|
|
||||||
// create sub-directories - recursively
|
|
||||||
err = CopyDir(sourcefilepointer, destinationfilepointer)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// perform copy
|
|
||||||
err = CopyFile(sourcefilepointer, destinationfilepointer)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
+130
-20
@@ -15,7 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/config"
|
"github.com/go-git/go-git/v5/config"
|
||||||
@@ -38,9 +38,11 @@ var (
|
|||||||
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.
|
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
||||||
// Only concurrent operations targeting the same directory are erialized; clones into different directories run in parallel.
|
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
|
||||||
func acquireCloneLock(dir string) func() {
|
// 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{})
|
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
|
||||||
mu := v.(*sync.Mutex)
|
mu := v.(*sync.Mutex)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -64,8 +66,21 @@ 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(
|
||||||
@@ -97,10 +112,13 @@ 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
|
||||||
}
|
}
|
||||||
@@ -172,6 +190,8 @@ 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"
|
||||||
}
|
}
|
||||||
@@ -237,14 +257,37 @@ type NewGitCloneExecutorInput struct {
|
|||||||
Token string
|
Token string
|
||||||
OfflineMode bool
|
OfflineMode bool
|
||||||
|
|
||||||
|
// Depth limits the clone/fetch to the given number of commits from the tip of the requested ref.
|
||||||
|
// 0 for full clone.
|
||||||
|
Depth int
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloneIfRequired ...
|
// CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused.
|
||||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) {
|
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
||||||
r, err := git.PlainOpen(input.Dir)
|
r, err := git.PlainOpen(input.Dir)
|
||||||
|
if err == nil {
|
||||||
|
// Verify the cached clone still points to the resolved URL before reusing it.
|
||||||
|
remote, err := r.Remote("origin")
|
||||||
|
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
|
||||||
|
// Reuse existing clone
|
||||||
|
return r, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||||
|
} else if len(remote.Config().URLs) == 0 {
|
||||||
|
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(input.Dir); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var progressWriter io.Writer
|
var progressWriter io.Writer
|
||||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||||
if entry, ok := logger.(*log.Entry); ok {
|
if entry, ok := logger.(*log.Entry); ok {
|
||||||
@@ -270,18 +313,17 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
|
r, err = cloneAtDepth(ctx, input, cloneOptions, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
||||||
return nil, err
|
return nil, false, err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return r, nil
|
return r, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
||||||
@@ -305,13 +347,13 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
|
|||||||
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(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref)
|
logger.Infof("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)()
|
defer AcquireCloneLock(input.Dir)()
|
||||||
|
|
||||||
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
||||||
r, err := CloneIfRequired(ctx, refName, input, logger)
|
r, reused, err := CloneIfRequired(ctx, refName, input, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -326,6 +368,16 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
pullOptions.InsecureSkipTLS = true
|
pullOptions.InsecureSkipTLS = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||||
|
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||||
|
shallow := isShallow(r)
|
||||||
|
if shallow {
|
||||||
|
fetchOptions.Depth = 1
|
||||||
|
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||||
|
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !isOfflineMode {
|
if !isOfflineMode {
|
||||||
err = r.Fetch(&fetchOptions)
|
err = r.Fetch(&fetchOptions)
|
||||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
@@ -336,10 +388,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(),
|
||||||
@@ -390,12 +442,20 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isOfflineMode {
|
|
||||||
|
reusedMsg := ""
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !isOfflineMode && !shallow:
|
||||||
|
// In shallow mode the depth-limited fetch above already advanced the ref.
|
||||||
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
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)
|
||||||
}
|
}
|
||||||
|
case isOfflineMode && reused:
|
||||||
|
reusedMsg = " (reused in offline mode)"
|
||||||
}
|
}
|
||||||
logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
|
|
||||||
|
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
||||||
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
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")
|
||||||
@@ -424,3 +484,53 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cloneAtDepth clones input.URL into input.Dir using opts.
|
||||||
|
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
||||||
|
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||||
|
if input.Depth > 0 {
|
||||||
|
for _, refName := range []plumbing.ReferenceName{
|
||||||
|
plumbing.NewBranchReferenceName(input.Ref),
|
||||||
|
plumbing.NewTagReferenceName(input.Ref),
|
||||||
|
} {
|
||||||
|
shallowOpts := opts
|
||||||
|
shallowOpts.Depth = input.Depth
|
||||||
|
shallowOpts.SingleBranch = true
|
||||||
|
shallowOpts.ReferenceName = refName
|
||||||
|
shallowOpts.Tags = git.NoTags
|
||||||
|
|
||||||
|
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||||
|
if err == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||||
|
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
||||||
|
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||||
|
func isShallow(r *git.Repository) bool {
|
||||||
|
shallows, err := r.Storer.Shallow()
|
||||||
|
return err == nil && len(shallows) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
||||||
|
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
||||||
|
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||||
|
tagRef := plumbing.NewTagReferenceName(ref)
|
||||||
|
if _, err := r.Reference(tagRef, false); err == nil {
|
||||||
|
return config.RefSpec(fmt.Sprintf("+%s:%s", tagRef, tagRef)), true
|
||||||
|
}
|
||||||
|
remoteRef := plumbing.NewRemoteReferenceName("origin", ref)
|
||||||
|
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||||
|
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||||
|
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
+225
-34
@@ -10,13 +10,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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"
|
||||||
)
|
)
|
||||||
@@ -50,10 +50,6 @@ 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)
|
||||||
@@ -78,8 +74,7 @@ func cleanGitHooks(dir string) error {
|
|||||||
func TestFindGitRemoteURL(t *testing.T) {
|
func TestFindGitRemoteURL(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
basedir := testDir(t)
|
basedir := t.TempDir()
|
||||||
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)
|
||||||
@@ -102,8 +97,7 @@ func TestFindGitRemoteURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGitFindRef(t *testing.T) {
|
func TestGitFindRef(t *testing.T) {
|
||||||
basedir := testDir(t)
|
basedir := t.TempDir()
|
||||||
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)
|
||||||
@@ -180,36 +174,55 @@ 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
|
||||||
URL, Ref string
|
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,
|
||||||
URL: "https://github.com/actions/checkout",
|
Ref: fullSha,
|
||||||
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
|
|
||||||
},
|
},
|
||||||
"short-sha": {
|
"short-sha": {
|
||||||
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"},
|
Err: &Error{ErrShortRef, fullSha},
|
||||||
URL: "https://github.com/actions/checkout",
|
Ref: fullSha[:7],
|
||||||
Ref: "5a4ac90", // v2
|
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: tt.URL,
|
URL: remoteDir,
|
||||||
Ref: tt.Ref,
|
Ref: tt.Ref,
|
||||||
Dir: testDir(t),
|
Dir: t.TempDir(),
|
||||||
})
|
})
|
||||||
|
|
||||||
err := clone(context.Background())
|
err := clone(context.Background())
|
||||||
@@ -223,13 +236,56 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||||
|
createRemote := func(message string) string {
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", message))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
|
return remoteDir
|
||||||
|
}
|
||||||
|
|
||||||
|
oldRemoteDir := createRemote("old-action")
|
||||||
|
newRemoteDir := createRemote("new-action")
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: oldRemoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
})(t.Context()))
|
||||||
|
|
||||||
|
markerPath := filepath.Join(cacheDir, "stale-marker")
|
||||||
|
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: newRemoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
})(t.Context()))
|
||||||
|
|
||||||
|
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, newRemoteDir, originURL)
|
||||||
|
|
||||||
|
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "new-action", strings.TrimSpace(string(out)))
|
||||||
|
|
||||||
|
_, err = os.Stat(markerPath)
|
||||||
|
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||||
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
||||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
// 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))
|
||||||
@@ -279,22 +335,157 @@ 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 gitConfig() {
|
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||||
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
// Build a local "remote" with a single commit on main.
|
||||||
var err error
|
remoteDir := t.TempDir()
|
||||||
if err = gitCmd("config", "--global", "user.email", "[email protected]"); err != nil {
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
log.Error(err)
|
workDir := t.TempDir()
|
||||||
}
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil {
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
log.Error(err)
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||||
}
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
|
// Prime the cache with an online clone of main.
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
})(context.Background()))
|
||||||
|
|
||||||
|
t.Run("cached branch resolves without fetching", func(t *testing.T) {
|
||||||
|
// Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref)
|
||||||
|
// finds no local refs/heads/<ref>.
|
||||||
|
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
OfflineMode: true,
|
||||||
|
})(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "initial", strings.TrimSpace(string(out)))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unresolvable cached ref returns error", func(t *testing.T) {
|
||||||
|
// The ref was never cached; offline mode cannot resolve it and must return an error.
|
||||||
|
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "never-fetched",
|
||||||
|
Dir: cacheDir,
|
||||||
|
OfflineMode: true,
|
||||||
|
})(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||||
|
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
for _, m := range []string{"c1", "c2", "c3"} {
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
||||||
}
|
}
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
|
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||||
|
|
||||||
|
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
||||||
|
|
||||||
|
t.Run("branch is cloned shallowly", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present")
|
||||||
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tag is cloned shallowly", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "v1", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
||||||
|
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
||||||
|
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
|
||||||
|
// Advance main on the remote, then reuse the existing shallow clone.
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
||||||
|
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRevCount(t *testing.T, dir string) int {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitHeadSubject(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%s").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitCmd(args ...string) error {
|
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",
|
||||||
|
"[email protected]",
|
||||||
|
"GIT_COMMITTER_NAME=Unit Test",
|
||||||
|
"[email protected]",
|
||||||
|
"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 {
|
||||||
@@ -310,11 +501,11 @@ func TestAcquireCloneLock(t *testing.T) {
|
|||||||
t.Run("same directory serializes", func(t *testing.T) {
|
t.Run("same directory serializes", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
unlock1 := acquireCloneLock(dir)
|
unlock1 := AcquireCloneLock(dir)
|
||||||
|
|
||||||
secondAcquired := make(chan struct{})
|
secondAcquired := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
unlock := acquireCloneLock(dir)
|
unlock := AcquireCloneLock(dir)
|
||||||
close(secondAcquired)
|
close(secondAcquired)
|
||||||
unlock()
|
unlock()
|
||||||
}()
|
}()
|
||||||
@@ -338,12 +529,12 @@ func TestAcquireCloneLock(t *testing.T) {
|
|||||||
dirA := t.TempDir()
|
dirA := t.TempDir()
|
||||||
dirB := t.TempDir()
|
dirB := t.TempDir()
|
||||||
|
|
||||||
unlockA := acquireCloneLock(dirA)
|
unlockA := AcquireCloneLock(dirA)
|
||||||
defer unlockA()
|
defer unlockA()
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
unlock := acquireCloneLock(dirB)
|
unlock := AcquireCloneLock(dirB)
|
||||||
unlock()
|
unlock()
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ func JobError(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SetJobError(ctx context.Context, err error) {
|
func SetJobError(ctx context.Context, err error) {
|
||||||
ctx.Value(jobErrorContextKeyVal).(map[string]error)["error"] = err
|
if container, ok := ctx.Value(jobErrorContextKeyVal).(map[string]error); ok {
|
||||||
|
container["error"] = err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithJobErrorContainer adds a value to the context as a container for an error
|
// WithJobErrorContainer adds a value to the context as a container for an error
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import (
|
|||||||
// LineHandler is a callback function for handling a line
|
// LineHandler is a callback function for handling a line
|
||||||
type LineHandler func(line string) bool
|
type LineHandler func(line string) bool
|
||||||
|
|
||||||
|
// Flusher is implemented by writers that buffer a trailing, not-yet-terminated
|
||||||
|
// line. Callers should flush once the underlying stream has reached EOF so the
|
||||||
|
// final line (when it is not newline-terminated) is not lost.
|
||||||
|
type Flusher interface {
|
||||||
|
Flush()
|
||||||
|
}
|
||||||
|
|
||||||
type lineWriter struct {
|
type lineWriter struct {
|
||||||
buffer bytes.Buffer
|
buffer bytes.Buffer
|
||||||
handlers []LineHandler
|
handlers []LineHandler
|
||||||
@@ -24,6 +31,14 @@ func NewLineWriter(handlers ...LineHandler) io.Writer {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlushWriter flushes w if it implements Flusher. It is a no-op otherwise, so
|
||||||
|
// callers can flush an io.Writer without knowing its concrete type.
|
||||||
|
func FlushWriter(w io.Writer) {
|
||||||
|
if f, ok := w.(Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
||||||
pBuf := bytes.NewBuffer(p)
|
pBuf := bytes.NewBuffer(p)
|
||||||
written := 0
|
written := 0
|
||||||
@@ -44,6 +59,17 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
|||||||
return written, nil
|
return written, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush emits any buffered, not-yet-newline-terminated content as a final line.
|
||||||
|
// It is safe to call multiple times; subsequent calls with an empty buffer are
|
||||||
|
// no-ops.
|
||||||
|
func (lw *lineWriter) Flush() {
|
||||||
|
if lw.buffer.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lw.handleLine(lw.buffer.String())
|
||||||
|
lw.buffer.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
func (lw *lineWriter) handleLine(line string) {
|
func (lw *lineWriter) handleLine(line string) {
|
||||||
for _, h := range lw.handlers {
|
for _, h := range lw.handlers {
|
||||||
ok := h(line)
|
ok := h(line)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -39,3 +40,33 @@ func TestLineWriter(t *testing.T) {
|
|||||||
assert.Equal(" and another\n", lines[2])
|
assert.Equal(" and another\n", lines[2])
|
||||||
assert.Equal("last line\n", lines[3])
|
assert.Equal("last line\n", lines[3])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLineWriterFlush(t *testing.T) {
|
||||||
|
lines := make([]string, 0)
|
||||||
|
lineHandler := func(s string) bool {
|
||||||
|
lines = append(lines, s)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
lineWriter := NewLineWriter(lineHandler)
|
||||||
|
|
||||||
|
assert := assert.New(t)
|
||||||
|
_, err := lineWriter.Write([]byte("complete line\npartial line without newline"))
|
||||||
|
assert.NoError(err) //nolint:testifylint // pre-existing pattern from nektos/act
|
||||||
|
|
||||||
|
// Only the newline-terminated line is emitted before flushing.
|
||||||
|
assert.Equal([]string{"complete line\n"}, lines)
|
||||||
|
|
||||||
|
// Flushing emits the buffered, not-yet-terminated trailing line.
|
||||||
|
FlushWriter(lineWriter)
|
||||||
|
assert.Equal([]string{"complete line\n", "partial line without newline"}, lines)
|
||||||
|
|
||||||
|
// Flushing again is a no-op: nothing is buffered.
|
||||||
|
FlushWriter(lineWriter)
|
||||||
|
assert.Len(lines, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlushWriterIgnoresNonFlusher(t *testing.T) {
|
||||||
|
// FlushWriter must be a safe no-op for writers that do not buffer lines.
|
||||||
|
assert.NotPanics(t, func() { FlushWriter(io.Discard) })
|
||||||
|
}
|
||||||
@@ -6,13 +6,21 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"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
|
||||||
@@ -39,6 +47,7 @@ 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
|
||||||
@@ -75,6 +84,12 @@ type NewDockerBuildExecutorInput struct {
|
|||||||
Platform string
|
Platform string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
||||||
|
type NewDockerNetworkCreateExecutorInput struct {
|
||||||
|
EnableIPv4 *bool
|
||||||
|
EnableIPv6 *bool
|
||||||
|
}
|
||||||
|
|
||||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||||
type NewDockerPullExecutorInput struct {
|
type NewDockerPullExecutorInput struct {
|
||||||
Image string
|
Image string
|
||||||
|
|||||||
@@ -8,34 +8,33 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/act_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/docker/cli/cli/config/credentials"
|
"github.com/moby/moby/api/types/registry"
|
||||||
"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, err := config.Load(config.Dir())
|
// config.LoadDefaultConfigFile panics on nil io.Writer when the config
|
||||||
|
// file is malformed; use config.Load to route errors through the logger.
|
||||||
|
cfg, err := config.Load(config.Dir())
|
||||||
if err != nil {
|
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 !config.ContainsAuth() {
|
if image != "" {
|
||||||
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
|
||||||
|
logger.Warnf("Could not normalize image reference: %v", refErr)
|
||||||
|
} else {
|
||||||
|
registryKey = registryAuthConfigKey(reference.Domain(registryRef))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hostName := "index.docker.io"
|
authConfig, err := cfg.GetAuthConfig(registryKey)
|
||||||
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
|
||||||
@@ -46,17 +45,16 @@ 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)
|
||||||
config, err := config.Load(config.Dir())
|
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 nil
|
return nil
|
||||||
}
|
}
|
||||||
|
creds, err := cfg.GetAllCredentials()
|
||||||
if !config.ContainsAuth() {
|
if err != nil {
|
||||||
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
|
logger.Warnf("Could not get docker auth configs: %v", err)
|
||||||
|
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)
|
||||||
@@ -64,3 +62,10 @@ 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
|
||||||
|
}
|
||||||
@@ -12,13 +12,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/moby/go-archive"
|
||||||
"github.com/docker/docker/pkg/archive"
|
"github.com/moby/go-archive/compression"
|
||||||
// github.com/docker/docker/builder/dockerignore is deprecated
|
"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
|
||||||
@@ -26,9 +27,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("%sdocker build -t %s --platform %s %s", logPrefix, input.ImageTag, input.Platform, input.ContextDir)
|
logger.Infof("docker build -t %s --platform %s %s", input.ImageTag, input.Platform, input.ContextDir)
|
||||||
} else {
|
} else {
|
||||||
logger.Infof("%sdocker build -t %s %s", logPrefix, input.ImageTag, input.ContextDir)
|
logger.Infof("docker build -t %s %s", input.ImageTag, input.ContextDir)
|
||||||
}
|
}
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -43,13 +44,19 @@ 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 := types.ImageBuildOptions{
|
options := client.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)
|
||||||
@@ -77,7 +84,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 = archive.CanonicalTarNameForPath(relDockerfile)
|
relDockerfile = filepath.ToSlash(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) {
|
||||||
@@ -87,7 +94,7 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
|||||||
|
|
||||||
var excludes []string
|
var excludes []string
|
||||||
if err == nil {
|
if err == nil {
|
||||||
excludes, err = dockerignore.ReadAll(f) //nolint:staticcheck // pre-existing issue from nektos/act
|
excludes, err = ignorefile.ReadAll(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -107,9 +114,8 @@ 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,
|
Compression: compression.None,
|
||||||
ExcludePatterns: excludes,
|
ExcludePatterns: excludes,
|
||||||
IncludeFiles: includes,
|
IncludeFiles: includes,
|
||||||
})
|
})
|
||||||
|
|||||||
+336
-204
File diff suppressed because it is too large.
Load diff
@@ -16,15 +16,18 @@ 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"
|
||||||
@@ -77,21 +80,21 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
|||||||
return flags, copts
|
return flags, copts
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig) {
|
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
config, hostConfig, _, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
config, hostConfig, networkingConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
return config, hostConfig
|
return config, hostConfig, networkingConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +143,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)
|
||||||
@@ -194,10 +197,10 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseWithVolumes(t *testing.T) {
|
func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from docker/cli tests
|
||||||
// A single volume
|
// 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)
|
||||||
@@ -205,7 +208,7 @@ func TestParseWithVolumes(t *testing.T) {
|
|||||||
|
|
||||||
// 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)
|
||||||
@@ -215,13 +218,13 @@ func TestParseWithVolumes(t *testing.T) {
|
|||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,26 +233,26 @@ func TestParseWithVolumes(t *testing.T) {
|
|||||||
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)
|
||||||
@@ -258,7 +261,7 @@ func TestParseWithVolumes(t *testing.T) {
|
|||||||
// 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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,6 +297,36 @@ 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"
|
||||||
@@ -301,9 +334,10 @@ func TestParseWithMacAddress(t *testing.T) {
|
|||||||
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
|
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)
|
||||||
}
|
}
|
||||||
if config, _ := mustParse(t, validMacAddress); config.MacAddress != "92:d0:c6:0a:29:33" { //nolint:staticcheck // pre-existing issue from nektos/act
|
_, hostConfig, networkingConfig := mustParse(t, validMacAddress)
|
||||||
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
|
endpoint := networkingConfig.EndpointsConfig[string(hostConfig.NetworkMode)]
|
||||||
}
|
assert.Check(t, endpoint != nil)
|
||||||
|
assert.Equal(t, "92:d0:c6:0a:29:33", endpoint.MacAddress.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunFlagsParseWithMemory(t *testing.T) {
|
func TestRunFlagsParseWithMemory(t *testing.T) {
|
||||||
@@ -312,7 +346,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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,10 +356,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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,14 +374,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,26 +395,28 @@ func TestParseHostnameDomainname(t *testing.T) {
|
|||||||
"domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors",
|
"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 := map[string]string{
|
invalids := []string{
|
||||||
":": "invalid port format for --expose: :",
|
":",
|
||||||
"8080:9090": "invalid port format for --expose: 8080:9090",
|
"8080:9090",
|
||||||
"NaN/tcp": `invalid range format for --expose: NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
"/tcp",
|
||||||
"NaN-NaN/tcp": `invalid range format for --expose: NaN-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
"/udp",
|
||||||
"8080-NaN/tcp": `invalid range format for --expose: 8080-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
|
"NaN/tcp",
|
||||||
"1234567890-8080/tcp": `invalid range format for --expose: 1234567890-8080/tcp, error: strconv.ParseUint: parsing "1234567890": value out of range`,
|
"NaN-NaN/tcp",
|
||||||
|
"8080-NaN/tcp",
|
||||||
|
"1234567890-8080/tcp",
|
||||||
}
|
}
|
||||||
valids := map[string][]nat.Port{
|
valids := map[string][]nat.Port{
|
||||||
"8080/tcp": {"8080/tcp"},
|
"8080/tcp": {"8080/tcp"},
|
||||||
@@ -389,9 +425,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, expectedError := range invalids {
|
for _, expose := range invalids {
|
||||||
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil || err.Error() != expectedError {
|
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil {
|
||||||
t.Fatalf("Expected error '%v' with '--expose=%v', got '%v'", expectedError, expose, err)
|
t.Fatalf("Expected error with '--expose=%v', got none", expose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for expose, exposedPorts := range valids {
|
for expose, exposedPorts := range valids {
|
||||||
@@ -403,7 +439,7 @@ func TestParseWithExpose(t *testing.T) {
|
|||||||
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
|
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[port]; !ok {
|
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
||||||
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
|
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,7 +454,7 @@ func TestParseWithExpose(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ports := []nat.Port{"80/tcp", "81/tcp"}
|
ports := []nat.Port{"80/tcp", "81/tcp"}
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
if _, ok := config.ExposedPorts[port]; !ok {
|
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
|
||||||
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
|
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,9 +534,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: "172.20.88.22",
|
IPv4Address: mustAddr(t, "172.20.88.22"),
|
||||||
IPv6Address: "2001:db8::8822",
|
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
||||||
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
LinkLocalIPs: mustAddrs(t, "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"},
|
||||||
@@ -527,9 +563,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: "172.20.88.22",
|
IPv4Address: mustAddr(t, "172.20.88.22"),
|
||||||
IPv6Address: "2001:db8::8822",
|
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
||||||
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
|
LinkLocalIPs: mustAddrs(t, "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"},
|
||||||
@@ -538,8 +574,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: "172.20.88.22",
|
IPv4Address: mustAddr(t, "172.20.88.22"),
|
||||||
IPv6Address: "2001:db8::8822",
|
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
||||||
},
|
},
|
||||||
Aliases: []string{"web3"},
|
Aliases: []string{"web3"},
|
||||||
},
|
},
|
||||||
@@ -556,8 +592,8 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
"field2": "value2",
|
"field2": "value2",
|
||||||
},
|
},
|
||||||
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
IPAMConfig: &networktypes.EndpointIPAMConfig{
|
||||||
IPv4Address: "172.20.88.22",
|
IPv4Address: mustAddr(t, "172.20.88.22"),
|
||||||
IPv6Address: "2001:db8::8822",
|
IPv6Address: mustAddr(t, "2001:db8::8822"),
|
||||||
},
|
},
|
||||||
Aliases: []string{"web1", "web2"},
|
Aliases: []string{"web1", "web2"},
|
||||||
},
|
},
|
||||||
@@ -610,7 +646,9 @@ 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)
|
||||||
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected)
|
if diff := cmp.Diff(tc.expected, nwConfig.EndpointsConfig, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
|
||||||
|
t.Fatalf("unexpected endpoints (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,7 +669,7 @@ func TestParseModes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// uts ko
|
// uts ko
|
||||||
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"})
|
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||||
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
||||||
|
|
||||||
// uts ok
|
// uts ok
|
||||||
@@ -691,10 +729,9 @@ func TestParseRestartPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||||
expected := "Conflicting options: --restart and --rm"
|
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"})
|
if err == nil {
|
||||||
if err == nil || err.Error() != expected {
|
t.Fatal("Expected error for conflicting --restart and --rm, but got none")
|
||||||
t.Fatalf("Expected error %v, but got none", expected)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,7 +789,7 @@ func TestParseLoggingOpts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act
|
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||||
e := "open nonexistent: no such file or directory"
|
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."
|
||||||
@@ -795,7 +832,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UTF16 with BOM
|
// UTF16 with BOM
|
||||||
e := "contains invalid utf8 bytes at line"
|
e := "invalid env file"
|
||||||
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
|
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)
|
||||||
}
|
}
|
||||||
@@ -805,7 +842,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act
|
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
|
||||||
e := "open nonexistent: no such file or directory"
|
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"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/client"
|
"github.com/moby/moby/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.ImageInspectWithRaw(ctx, imageName)
|
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
||||||
if client.IsErrNotFound(err) {
|
if cerrdefs.IsNotFound(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.ImageInspectWithRaw(ctx, imageName)
|
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
||||||
if client.IsErrNotFound(err) {
|
if cerrdefs.IsNotFound(err) {
|
||||||
return false, nil
|
return false, nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, types.ImageRemoveOptions{
|
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
|
||||||
Force: force,
|
Force: force,
|
||||||
PruneChildren: pruneChildren,
|
PruneChildren: pruneChildren,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
@@ -6,66 +6,64 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"fmt"
|
||||||
|
"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) {
|
||||||
if testing.Short() {
|
requireDocker(t)
|
||||||
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
|
|
||||||
|
|
||||||
// Test if image exists with specific tag
|
|
||||||
invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.False(t, invalidImageTag)
|
|
||||||
|
|
||||||
// Test if image exists with specific architecture (image platform)
|
|
||||||
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.False(t, invalidImagePlatform)
|
|
||||||
|
|
||||||
// pull an image
|
// a non-existent image is reported absent
|
||||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
cli.NegotiateAPIVersion(context.Background())
|
assert.False(t, missing)
|
||||||
|
|
||||||
// Chose alpine latest because it's so small
|
// Build tiny images for two architectures locally so per-platform existence can be checked
|
||||||
// maybe we should build an image instead so that tests aren't reliable on dockerhub
|
// offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network).
|
||||||
readerDefault, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
|
amd64Ref := buildScratchImage(t, "linux/amd64")
|
||||||
Platform: "linux/amd64",
|
arm64Ref := buildScratchImage(t, "linux/arm64")
|
||||||
})
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
defer readerDefault.Close()
|
|
||||||
_, err = io.ReadAll(readerDefault)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/amd64")
|
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.True(t, imageDefaultArchExists)
|
assert.True(t, amd64Exists)
|
||||||
|
|
||||||
// Validate if another architecture platform can be pulled
|
// a non-host architecture image is detected for its own architecture
|
||||||
readerArm64, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
|
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
|
||||||
Platform: "linux/arm64",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
defer readerArm64.Close()
|
|
||||||
_, err = io.ReadAll(readerArm64)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
assert.True(t, arm64Exists)
|
||||||
|
|
||||||
imageArm64Exists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/arm64")
|
// a present image is reported absent for a different platform
|
||||||
|
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.True(t, imageArm64Exists)
|
assert.False(t, wrongPlatform)
|
||||||
}
|
}
|
||||||
@@ -26,8 +26,6 @@ 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
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -23,22 +23,24 @@ 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, types.NetworkListOptions{})
|
networks, err := cli.NetworkList(ctx, client.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 _, network := range networks {
|
for _, n := range networks.Items {
|
||||||
if network.Name == name {
|
if n.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, types.NetworkCreate{
|
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||||
Driver: "bridge",
|
Driver: "bridge",
|
||||||
Scope: "local",
|
Scope: "local",
|
||||||
|
EnableIPv4: opts.EnableIPv4,
|
||||||
|
EnableIPv6: opts.EnableIPv6,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -56,23 +58,23 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
// Make shure that all network of the specified name are removed
|
// Make sure that all network of the specified name are removed
|
||||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
||||||
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||||
if err != nil {
|
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 _, network := range networks {
|
for _, n := range networks.Items {
|
||||||
if network.Name == name {
|
if n.Name == name {
|
||||||
result, err := cli.NetworkInspect(ctx, network.ID, types.NetworkInspectOptions{})
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Containers) == 0 {
|
if len(result.Network.Containers) == 0 {
|
||||||
if err = cli.NetworkRemove(ctx, network.ID); err != nil {
|
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||||
common.Logger(ctx).Debugf("%v", err)
|
common.Logger(ctx).Debugf("%v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2025 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parsePlatform parses an "os/arch[/variant]" string into a Platform. An empty input
|
||||||
|
// returns (nil, nil), meaning "no platform constraint". A non-empty but malformed
|
||||||
|
// string is rejected explicitly so it cannot silently fall through to the daemon's
|
||||||
|
// default architecture.
|
||||||
|
func parsePlatform(platform string) (*specs.Platform, error) {
|
||||||
|
if platform == "" {
|
||||||
|
return nil, nil //nolint:nilnil // no platform constraint requested
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(platform, "/")
|
||||||
|
if len(parts) < 2 || len(parts) > 3 || parts[0] == "" || parts[1] == "" || (len(parts) == 3 && parts[2] == "") {
|
||||||
|
return nil, fmt.Errorf("invalid platform %q: expected os/arch[/variant]", platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec := &specs.Platform{
|
||||||
|
OS: strings.ToLower(parts[0]),
|
||||||
|
Architecture: strings.ToLower(parts[1]),
|
||||||
|
}
|
||||||
|
if len(parts) == 3 {
|
||||||
|
spec.Variant = strings.ToLower(parts[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePlatform(t *testing.T) {
|
||||||
|
t.Run("empty input returns nil platform without error", func(t *testing.T) {
|
||||||
|
got, err := parsePlatform("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, got)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("os/arch", func(t *testing.T) {
|
||||||
|
got, err := parsePlatform("linux/amd64")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, "linux", got.OS)
|
||||||
|
assert.Equal(t, "amd64", got.Architecture)
|
||||||
|
assert.Empty(t, got.Variant)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("os/arch/variant", func(t *testing.T) {
|
||||||
|
got, err := parsePlatform("linux/arm/v7")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, "linux", got.OS)
|
||||||
|
assert.Equal(t, "arm", got.Architecture)
|
||||||
|
assert.Equal(t, "v7", got.Variant)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("input is lowercased", func(t *testing.T) {
|
||||||
|
got, err := parsePlatform("Linux/AMD64/V8")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, "linux", got.OS)
|
||||||
|
assert.Equal(t, "amd64", got.Architecture)
|
||||||
|
assert.Equal(t, "v8", got.Variant)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, bad := range []string{
|
||||||
|
"amd64",
|
||||||
|
"linux",
|
||||||
|
"linux/",
|
||||||
|
"/amd64",
|
||||||
|
"/",
|
||||||
|
"//",
|
||||||
|
"linux/arm/",
|
||||||
|
"linux/arm/v7/extra",
|
||||||
|
} {
|
||||||
|
t.Run("rejects "+bad, func(t *testing.T) {
|
||||||
|
got, err := parsePlatform(bad)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,23 +8,23 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/moby/moby/api/pkg/authconfig"
|
||||||
"github.com/docker/docker/api/types/registry"
|
"github.com/moby/moby/api/types/registry"
|
||||||
|
"github.com/moby/moby/client"
|
||||||
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewDockerPullExecutor function to create a run executor for the container
|
// 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("%sdocker pull %v", logPrefix, input.Image)
|
logger.Debugf("docker pull %v", input.Image)
|
||||||
|
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -78,26 +78,29 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (types.ImagePullOptions, error) {
|
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
||||||
imagePullOptions := types.ImagePullOptions{
|
imagePullOptions := client.ImagePullOptions{}
|
||||||
Platform: input.Platform,
|
platform, err := parsePlatform(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")
|
||||||
|
|
||||||
authConfig := registry.AuthConfig{
|
encodedAuth, err := authconfig.Encode(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 = base64.URLEncoding.EncodeToString(encodedJSON)
|
imagePullOptions.RegistryAuth = encodedAuth
|
||||||
} else {
|
} else {
|
||||||
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
|
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,19 +111,17 @@ func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput)
|
|||||||
}
|
}
|
||||||
logger.Info("using DockerAuthConfig authentication for docker pull")
|
logger.Info("using DockerAuthConfig authentication for docker pull")
|
||||||
|
|
||||||
encodedJSON, err := json.Marshal(authConfig)
|
imagePullOptions.RegistryAuth, err = authconfig.Encode(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, image string) string {
|
func cleanImage(ctx context.Context, imageName string) string {
|
||||||
ref, err := reference.ParseAnyReference(image)
|
ref, err := reference.ParseAnyReference(imageName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Error(err)
|
common.Logger(ctx).Error(err)
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ 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{})
|
||||||
|
|||||||
+322
-96
File diff suppressed because it is too large.
Load diff
@@ -8,27 +8,33 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
"github.com/docker/docker/client"
|
"github.com/moby/moby/api/types/container"
|
||||||
|
mobyclient "github.com/moby/moby/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)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
require.NoError(t, err)
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||||
@@ -66,28 +72,48 @@ func TestDocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type mockDockerClient struct {
|
type mockDockerClient struct {
|
||||||
client.APIClient
|
mobyclient.APIClient
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerExecCreate(ctx context.Context, id string, opts types.ExecConfig) (types.IDResponse, error) {
|
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(types.IDResponse), args.Error(1)
|
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerExecAttach(ctx context.Context, id string, opts types.ExecStartCheck) (types.HijackedResponse, error) {
|
func (m *mockDockerClient) ExecAttach(ctx context.Context, id string, opts mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(types.HijackedResponse), args.Error(1)
|
return args.Get(0).(mobyclient.ExecAttachResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerExecInspect(ctx context.Context, execID string) (types.ContainerExecInspect, error) {
|
func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts mobyclient.ExecInspectOptions) (mobyclient.ExecInspectResult, error) {
|
||||||
args := m.Called(ctx, execID)
|
args := m.Called(ctx, execID, opts)
|
||||||
return args.Get(0).(types.ContainerExecInspect), args.Error(1)
|
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id, path string, content io.Reader, options types.CopyToContainerOptions) error {
|
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
||||||
args := m.Called(ctx, id, path, content, options)
|
args := m.Called(ctx, containerID, opts)
|
||||||
return args.Error(0)
|
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
||||||
|
args := m.Called(ctx, containerID, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerWaitResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, options mobyclient.CopyToContainerOptions) (mobyclient.CopyToContainerResult, error) {
|
||||||
|
args := m.Called(ctx, id, options)
|
||||||
|
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||||
|
args := m.Called(ctx, id, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
|
||||||
|
args := m.Called(ctx, opts)
|
||||||
|
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
type endlessReader struct {
|
type endlessReader struct {
|
||||||
@@ -119,10 +145,12 @@ 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("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
|
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||||
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
|
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
Conn: conn,
|
Conn: conn,
|
||||||
Reader: bufio.NewReader(endlessReader{}),
|
Reader: bufio.NewReader(endlessReader{}),
|
||||||
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
@@ -156,12 +184,14 @@ func TestDockerExecFailure(t *testing.T) {
|
|||||||
conn := &mockConn{}
|
conn := &mockConn{}
|
||||||
|
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
|
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||||
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
|
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
Conn: conn,
|
Conn: conn,
|
||||||
Reader: bufio.NewReader(strings.NewReader("output")),
|
Reader: bufio.NewReader(strings.NewReader("output")),
|
||||||
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
client.On("ContainerExecInspect", ctx, "id").Return(types.ContainerExecInspect{
|
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
|
||||||
ExitCode: 1,
|
ExitCode: 1,
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
@@ -174,20 +204,121 @@ 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)
|
||||||
assert.Error(t, err, "exit with `FAILURE`: 1") //nolint:testifylint // pre-existing issue from nektos/act
|
var exitErr ExitCodeError
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stdcopyFrame wraps payload in a single Docker multiplexed-stream frame, the
|
||||||
|
// format StdCopy expects: an 8-byte header (stream type + 4-byte big-endian
|
||||||
|
// length) followed by the payload.
|
||||||
|
func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
||||||
|
b := make([]byte, 8+len(payload))
|
||||||
|
b[0] = byte(stream)
|
||||||
|
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
|
||||||
|
copy(b[8:], payload)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDockerAttachFlushesTrailingLine verifies that wait() blocks until the
|
||||||
|
// attach() streaming goroutine has drained and flushed the container's output,
|
||||||
|
// so a final line without a trailing newline is not lost.
|
||||||
|
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
logWriter := common.NewLineWriter(func(s string) bool {
|
||||||
|
lines = append(lines, s)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
||||||
|
Return(mobyclient.ContainerAttachResult{
|
||||||
|
HijackedResponse: mobyclient.HijackedResponse{
|
||||||
|
Conn: &mockConn{},
|
||||||
|
Reader: bufio.NewReader(framed),
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
|
statusCh <- container.WaitResponse{StatusCode: 0}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
||||||
|
Return(mobyclient.ContainerWaitResult{
|
||||||
|
Result: (<-chan container.WaitResponse)(statusCh),
|
||||||
|
Error: (<-chan error)(errCh),
|
||||||
|
})
|
||||||
|
|
||||||
|
cr := &containerReference{
|
||||||
|
id: "123",
|
||||||
|
cli: client,
|
||||||
|
input: &NewContainerInput{
|
||||||
|
Image: "image",
|
||||||
|
Stdout: logWriter,
|
||||||
|
Stderr: logWriter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, cr.attach()(ctx))
|
||||||
|
require.NoError(t, cr.wait()(ctx))
|
||||||
|
|
||||||
|
// wait() must have blocked until the goroutine drained AND flushed; the
|
||||||
|
// trailing, non-newline-terminated line must therefore be present. Reading
|
||||||
|
// lines here is race-free because wait() synchronizes on attachDone, which
|
||||||
|
// the goroutine closes after the final append.
|
||||||
|
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
|
||||||
|
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerWaitFailure(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
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.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
return opts.DestinationPath == "/" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
id: "123",
|
id: "123",
|
||||||
cli: client,
|
cli: client,
|
||||||
@@ -198,20 +329,18 @@ 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.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
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
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
id: "123",
|
id: "123",
|
||||||
cli: client,
|
cli: client,
|
||||||
@@ -223,20 +352,21 @@ 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.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
|
return opts.DestinationPath == "/" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||||
|
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||||
|
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||||
|
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
id: "123",
|
id: "123",
|
||||||
cli: client,
|
cli: client,
|
||||||
@@ -248,10 +378,137 @@ 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{}
|
||||||
|
|
||||||
@@ -327,3 +584,40 @@ 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,9 +18,19 @@ 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
|
||||||
CommonSocketLocations = originalCommonSocketLocations
|
isolateSocketEnv(t)
|
||||||
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)
|
||||||
@@ -48,9 +58,9 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
|
|||||||
|
|
||||||
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
|
||||||
@@ -65,7 +75,7 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
CommonSocketLocations = originalCommonSocketLocations
|
isolateSocketEnv(t)
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
@@ -79,7 +89,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
CommonSocketLocations = originalCommonSocketLocations
|
isolateSocketEnv(t)
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
defaultSocket, found := socketLocation()
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
@@ -97,6 +107,7 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
|||||||
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
// > 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
|
||||||
@@ -119,6 +130,7 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
|||||||
|
|
||||||
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"}
|
||||||
@@ -136,6 +148,7 @@ 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")
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/moby/moby/api/types/system"
|
||||||
"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 types.Info, err error) {
|
func GetHostInfo(ctx context.Context) (info system.Info, err error) {
|
||||||
return types.Info{}, nil
|
return system.Info{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
||||||
@@ -61,7 +61,7 @@ func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/moby/moby/client"
|
||||||
"github.com/docker/docker/api/types/volume"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||||
@@ -23,12 +22,12 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
list, err := cli.VolumeList(ctx, volume.ListOptions{Filters: filters.NewArgs()})
|
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, vol := range list.Volumes {
|
for _, vol := range list.Items {
|
||||||
if vol.Name == volumeName {
|
if vol.Name == volumeName {
|
||||||
return removeExecutor(volumeName, force)(ctx)
|
return removeExecutor(volumeName, force)(ctx)
|
||||||
}
|
}
|
||||||
@@ -42,7 +41,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("%sdocker volume rm %s", logPrefix, volume)
|
logger.Debugf("docker volume rm %s", volume)
|
||||||
|
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -54,6 +53,7 @@ func removeExecutor(volume string, force bool) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
return cli.VolumeRemove(ctx, volume, force)
|
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mobyclient "github.com/moby/moby/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireDocker skips the test unless a reachable docker daemon is available.
|
||||||
|
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
||||||
|
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||||
|
func requireDocker(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||||
|
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,13 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/act_runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/act_runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
"gitea.com/gitea/act_runner/act/lookpath"
|
"gitea.com/gitea/runner/act/lookpath"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/process"
|
||||||
|
|
||||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||||
"github.com/go-git/go-billy/v5/osfs"
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
@@ -34,9 +36,13 @@ 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
|
||||||
|
// when Workdir points at a caller-owned checkout (e.g. `act` local mode).
|
||||||
|
CleanWorkdir bool
|
||||||
ActPath string
|
ActPath string
|
||||||
CleanUp func()
|
CleanUp func()
|
||||||
StdOut io.Writer
|
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 {
|
||||||
@@ -192,12 +198,12 @@ func (e *HostEnvironment) Start(_ bool) common.Executor {
|
|||||||
|
|
||||||
type ptyWriter struct {
|
type ptyWriter struct {
|
||||||
Out io.Writer
|
Out io.Writer
|
||||||
AutoStop bool
|
AutoStop atomic.Bool
|
||||||
dirtyLine bool
|
dirtyLine bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *ptyWriter) Write(buf []byte) (int, error) {
|
func (w *ptyWriter) Write(buf []byte) (int, error) {
|
||||||
if w.AutoStop && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
||||||
n, err := w.Out.Write(buf[:len(buf)-1])
|
n, err := w.Out.Write(buf[:len(buf)-1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
@@ -256,7 +262,7 @@ func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
|||||||
cmd.Stdin = tty
|
cmd.Stdin = tty
|
||||||
cmd.Stdout = tty
|
cmd.Stdout = tty
|
||||||
cmd.Stderr = tty
|
cmd.Stderr = tty
|
||||||
cmd.SysProcAttr = getSysProcAttr(cmdline, true)
|
cmd.SysProcAttr = process.SysProcAttr(cmdline, true)
|
||||||
return ppty, tty, nil
|
return ppty, tty, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +322,15 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
cmd.Env = envList
|
cmd.Env = envList
|
||||||
cmd.Stderr = e.StdOut
|
cmd.Stderr = e.StdOut
|
||||||
cmd.Dir = wd
|
cmd.Dir = wd
|
||||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
||||||
|
|
||||||
|
// Kill the step's whole process tree on cancellation (a step often launches a
|
||||||
|
// shell that spawns further background or GUI children) and bound the post-exit
|
||||||
|
// I/O wait, so an orphan inheriting cmd's stdout/stderr pipe can never hang
|
||||||
|
// cmd.Wait() and the runner. See process.TreeKill. The PTY path below may
|
||||||
|
// override SysProcAttr, but never touches Cancel/WaitDelay.
|
||||||
|
treeKill := process.NewTreeKill(cmd)
|
||||||
|
|
||||||
var ppty *os.File
|
var ppty *os.File
|
||||||
var tty *os.File
|
var tty *os.File
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -327,36 +341,44 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
tty.Close()
|
tty.Close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if true /* allocate Terminal */ {
|
if e.AllocatePTY {
|
||||||
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writer := &ptyWriter{Out: e.StdOut}
|
var writer *ptyWriter
|
||||||
logctx, finishLog := context.WithCancel(context.Background())
|
var logctx context.Context
|
||||||
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)
|
||||||
}
|
}
|
||||||
err = cmd.Run()
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
|
||||||
|
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||||
|
} else {
|
||||||
|
defer k.Close()
|
||||||
|
}
|
||||||
|
err = cmd.Wait()
|
||||||
if err != nil {
|
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 = true
|
writer.AutoStop.Store(true)
|
||||||
if _, err := tty.WriteString("\x04"); err != nil {
|
if _, err := tty.WriteString("\x04"); err != nil {
|
||||||
common.Logger(ctx).Debug("Failed to write EOT")
|
common.Logger(ctx).Debug("Failed to write EOT")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
<-logctx.Done()
|
<-logctx.Done()
|
||||||
|
|
||||||
if ppty != nil {
|
|
||||||
ppty.Close()
|
ppty.Close()
|
||||||
ppty = nil
|
ppty = nil
|
||||||
}
|
}
|
||||||
@@ -385,12 +407,205 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
|
|||||||
return parseEnvFile(e, srcPath, env)
|
return parseEnvFile(e, srcPath, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeAll is the filesystem delete used by removeAllWithContext. A package
|
||||||
|
// var so tests can substitute a blocking stub without patching os.RemoveAll.
|
||||||
|
var removeAll = os.RemoveAll
|
||||||
|
|
||||||
|
// removeAllWithContext runs removeAll in a goroutine and returns once it
|
||||||
|
// finishes or ctx is cancelled. On cancellation the goroutine is left running —
|
||||||
|
// a delete blocked inside a syscall cannot be interrupted (see runWithTimeout).
|
||||||
|
func removeAllWithContext(ctx context.Context, path string) error {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- removeAll(path) }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePathWithRetry(ctx context.Context, path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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 = removeAllWithContext(ctx, path)
|
||||||
|
if lastErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(lastErr, context.DeadlineExceeded) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostCleanupTimeout bounds each filesystem-teardown phase of the host
|
||||||
|
// environment so a single stalled delete cannot wedge the runner slot forever.
|
||||||
|
// A var (not const) so tests can shrink it.
|
||||||
|
var hostCleanupTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// runWithTimeout runs fn in a goroutine and returns once it finishes or timeout
|
||||||
|
// elapses, whichever comes first. On timeout the goroutine is left running — an
|
||||||
|
// os.RemoveAll blocked inside a delete syscall (AV/EDR filter drivers, an
|
||||||
|
// unresponsive network mount, a dying disk) cannot be interrupted — and
|
||||||
|
// context.DeadlineExceeded is returned. Leaking the goroutine and the scratch
|
||||||
|
// state it was deleting is strictly better than blocking the caller forever and
|
||||||
|
// permanently losing the runner's capacity slot; the leaked scratch dir is
|
||||||
|
// reclaimed later by the runner's idle stale-dir sweep.
|
||||||
|
func runWithTimeout(fn func(), timeout time.Duration) error {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return nil
|
||||||
|
case <-timer.C:
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) Remove() common.Executor {
|
func (e *HostEnvironment) Remove() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// Bound it: CleanUp is a caller-supplied, typically unbounded os.RemoveAll,
|
||||||
|
// and a delete stalled by a filesystem filter driver would otherwise hang
|
||||||
|
// the job forever at "Cleaning up container" and hold the capacity slot.
|
||||||
if e.CleanUp != nil {
|
if e.CleanUp != nil {
|
||||||
e.CleanUp()
|
logger.Debugf("running host environment cleanup callback")
|
||||||
|
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
|
||||||
|
logger.Warnf("host environment cleanup did not finish within %s; continuing job completion, scratch state may be leaked and is reclaimed by the idle stale-dir sweep", hostCleanupTimeout)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("host environment cleanup callback finished")
|
||||||
}
|
}
|
||||||
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(), hostCleanupTimeout)
|
||||||
|
defer rmCancel()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, err := range errs {
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bounded teardown timed out; warnings already logged above. Do not
|
||||||
|
// fail job completion — leaked scratch is reclaimed by the idle sweep.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,22 @@ 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"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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
|
||||||
@@ -69,3 +77,287 @@ 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 TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
stubDone := make(chan struct{})
|
||||||
|
|
||||||
|
orig := removeAll
|
||||||
|
removeAll = func(string) error {
|
||||||
|
defer close(stubDone)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// removeAllWithContext intentionally leaks the delete goroutine on timeout,
|
||||||
|
// and that goroutine still references removeAll. Unblock it and wait for it
|
||||||
|
// to return before restoring the var, so the restore can't race the read.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(release)
|
||||||
|
<-stubDone
|
||||||
|
removeAll = orig
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := removeAllWithContext(ctx, t.TempDir())
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp guards against a stalled
|
||||||
|
// CleanUp callback (e.g. an os.RemoveAll blocked by an AV/EDR filter driver or
|
||||||
|
// an unresponsive mount) wedging the runner slot forever at "Cleaning up
|
||||||
|
// container". Remove must time out the callback and complete job teardown.
|
||||||
|
func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) {
|
||||||
|
// Keep the suite fast: shrink the per-phase teardown timeout for this test.
|
||||||
|
orig := hostCleanupTimeout
|
||||||
|
hostCleanupTimeout = 100 * time.Millisecond
|
||||||
|
t.Cleanup(func() { hostCleanupTimeout = orig })
|
||||||
|
|
||||||
|
logger := logrus.New()
|
||||||
|
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||||
|
base := t.TempDir()
|
||||||
|
path := filepath.Join(base, "misc", "hostexecutor")
|
||||||
|
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
t.Cleanup(func() { close(release) }) // unblock the leaked goroutine at test end
|
||||||
|
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: path,
|
||||||
|
CleanUp: func() {
|
||||||
|
<-release // simulate a delete syscall stuck indefinitely
|
||||||
|
},
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- e.Remove()(ctx) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Remove() hung on a stuck CleanUp callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval guards against a
|
||||||
|
// stalled os.RemoveAll on the misc/workspace paths (same AV/EDR wedge as
|
||||||
|
// #1023) wedging job completion after the CleanUp callback has already timed
|
||||||
|
// out or finished.
|
||||||
|
func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) {
|
||||||
|
origTimeout := hostCleanupTimeout
|
||||||
|
hostCleanupTimeout = 100 * time.Millisecond
|
||||||
|
t.Cleanup(func() { hostCleanupTimeout = origTimeout })
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
stubDone := make(chan struct{})
|
||||||
|
|
||||||
|
origRemoveAll := removeAll
|
||||||
|
removeAll = func(string) error {
|
||||||
|
defer close(stubDone)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The stuck delete goroutine outlives the timed-out Remove and still reads
|
||||||
|
// removeAll; unblock it and wait before restoring to avoid a restore/read race.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(release)
|
||||||
|
<-stubDone
|
||||||
|
removeAll = origRemoveAll
|
||||||
|
})
|
||||||
|
|
||||||
|
logger := logrus.New()
|
||||||
|
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||||
|
base := t.TempDir()
|
||||||
|
path := filepath.Join(base, "misc", "hostexecutor")
|
||||||
|
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||||
|
|
||||||
|
e := &HostEnvironment{
|
||||||
|
Path: path,
|
||||||
|
StdOut: os.Stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- e.Remove()(ctx) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Remove() hung on a stuck path removal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
|
||||||
|
t.Run("single dir", func(t *testing.T) {
|
||||||
|
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
|
||||||
|
assert.Contains(t, s, `$paths = @('C:\workspace\job1')`)
|
||||||
|
// Self-PID guard is essential — without it the script could taskkill
|
||||||
|
// the PowerShell process running it.
|
||||||
|
assert.Contains(t, s, "$selfPid = $PID")
|
||||||
|
assert.Contains(t, s, "$_.ProcessId -eq $selfPid")
|
||||||
|
// Must match both ExecutablePath (binaries from the workspace) and
|
||||||
|
// CommandLine (system binaries invoked with workspace paths in args),
|
||||||
|
// both bounded by dir+separator so a name-prefix sibling is spared.
|
||||||
|
assert.Contains(t, s, `$prefix = $p + '\'`)
|
||||||
|
assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix")
|
||||||
|
assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix")
|
||||||
|
// Each matched PID must be tree-killed, not just stopped.
|
||||||
|
assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple dirs comma-separated", func(t *testing.T) {
|
||||||
|
s := buildWindowsWorkspaceKillScript([]string{
|
||||||
|
`C:\work\path`,
|
||||||
|
`C:\work\workdir`,
|
||||||
|
`C:\Users\runner\AppData\Local\Temp\job-42`,
|
||||||
|
})
|
||||||
|
assert.Contains(t, s, `'C:\work\path'`)
|
||||||
|
assert.Contains(t, s, `'C:\work\workdir'`)
|
||||||
|
assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`)
|
||||||
|
// Commas between entries — no trailing comma, no leading comma.
|
||||||
|
assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("path with single quote is escaped", func(t *testing.T) {
|
||||||
|
// In PowerShell single-quoted strings the only special char is the
|
||||||
|
// quote itself, escaped by doubling. A workspace path that ever
|
||||||
|
// contained `'` would inject a command into the script otherwise.
|
||||||
|
s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`})
|
||||||
|
assert.Contains(t, s, `'C:\work\it''s\path'`)
|
||||||
|
// And it must NOT appear unescaped — otherwise the quote would
|
||||||
|
// terminate the literal early.
|
||||||
|
assert.NotContains(t, s, `'C:\work\it's\path'`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) {
|
||||||
|
// A path containing [ ] ? * must be embedded verbatim and matched with
|
||||||
|
// ordinal String methods, not -like, otherwise the metacharacters would
|
||||||
|
// be interpreted as wildcards and the leftover process could escape.
|
||||||
|
s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`})
|
||||||
|
assert.Contains(t, s, `'C:\work\[job]?1'`)
|
||||||
|
assert.NotContains(t, s, "-like")
|
||||||
|
assert.Contains(t, s, "StartsWith")
|
||||||
|
assert.Contains(t, s, "IndexOf")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty dir list still produces a valid script", func(t *testing.T) {
|
||||||
|
s := buildWindowsWorkspaceKillScript(nil)
|
||||||
|
// Empty array literal — script runs, matches nothing, is a no-op.
|
||||||
|
assert.Contains(t, s, "$paths = @()")
|
||||||
|
assert.Contains(t, s, "Get-CimInstance Win32_Process")
|
||||||
|
})
|
||||||
|
}
|
||||||
Loaded 50 of 1264 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user