mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-12 19:03:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81add27459 | ||
|
|
ace8653105 | ||
|
|
95daa50d9b | ||
|
|
1952e5cdb6 | ||
|
|
7ad12038d1 | ||
|
|
98816a605c | ||
|
|
e0d00e543d | ||
|
|
ae9ce10f71 | ||
|
|
3962d0d970 | ||
|
|
d83fa77de4 | ||
|
|
58ed5921f3 | ||
|
|
00ff71a879 | ||
|
|
80db5f61ac | ||
|
|
5310d8119a | ||
|
|
b9c6305dee | ||
|
|
699a97fc8c | ||
|
|
19afebc53f | ||
|
|
2cc3000369 | ||
|
|
3d116eb0c2 | ||
|
|
e0ce6776c7 | ||
|
|
69d5c22300 | ||
|
|
54978255f5 | ||
|
|
498282caaa | ||
|
|
2ed8cdb76e | ||
|
|
ff9965e940 | ||
|
|
ba4d3c5b4f | ||
|
|
9e3647395a | ||
|
|
c158ac5472 | ||
|
|
1745c7c841 | ||
|
|
7f53eca716 | ||
|
|
fca6b44c60 | ||
|
|
b9018aca31 | ||
|
|
6c77065295 | ||
|
|
235c035003 | ||
|
|
12dc9d26a2 | ||
|
|
d9f4d65545 | ||
|
|
212909db7b | ||
|
|
0712b2a7a1 | ||
|
|
34df4887af | ||
|
|
745a1e70e6 | ||
|
|
e30c2fed62 | ||
|
|
7b4356c746 | ||
|
|
e78123cee3 | ||
|
|
8260e2def2 | ||
|
|
b97c61aa14 | ||
|
|
546eca312e | ||
|
|
a0c4de79f7 | ||
|
|
dfe979e1d0 | ||
|
|
11ac12efa4 | ||
|
|
bc8161c673 | ||
|
|
3f70822458 | ||
|
|
be90c01468 | ||
|
|
6c6a878403 | ||
|
|
f97680a68d | ||
|
|
dbd9a892f8 | ||
|
|
e178c03adc | ||
|
|
b66433e667 | ||
|
|
a8dcd5b67c | ||
|
|
da9b559fb5 | ||
|
|
9dd9204937 | ||
|
|
24c13a1fd0 | ||
|
|
4c2ab943a8 | ||
|
|
20497aaf4f | ||
|
|
09b643bc14 | ||
|
|
1d6c6ffef9 | ||
|
|
70387cca44 | ||
|
|
68547886a5 | ||
|
|
8700adc933 | ||
|
|
b70ff6893a |
No files matched your search
@@ -20,11 +20,10 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# Custom publishers (the R2 mirror below) run as the very last
|
# Custom publishers (the R2 upload below) run as the very last
|
||||||
# step of goreleaser's publish pipeline, after the Gitea release
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
# has already been created and every artifact already uploaded
|
# has already been created. Fail here instead, before anything
|
||||||
# to S3. Fail here instead, before anything is built or
|
# is built or published, if the R2 secrets are missing.
|
||||||
# published, if the R2 secrets are missing.
|
|
||||||
- name: check R2 configuration
|
- name: check R2 configuration
|
||||||
run: sh scripts/upload-r2.sh --check-config
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
env:
|
env:
|
||||||
@@ -35,6 +34,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
check-latest: true
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||||
with:
|
with:
|
||||||
@@ -42,11 +42,6 @@ jobs:
|
|||||||
args: release --nightly
|
args: release --nightly
|
||||||
env:
|
env:
|
||||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
|
||||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
|
||||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
@@ -79,10 +74,10 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
@@ -99,7 +94,7 @@ jobs:
|
|||||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
# Custom publishers (the R2 mirror below) run as the very last
|
# Custom publishers (the R2 upload below) run as the very last
|
||||||
# step of goreleaser's publish pipeline, after the Gitea release
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
# has already been created and every artifact already uploaded
|
# has already been created. Fail here instead, before anything
|
||||||
# to S3. Fail here instead, before anything is built or
|
# is built or published, if the R2 secrets are missing.
|
||||||
# published, if the R2 secrets are missing.
|
|
||||||
- name: check R2 configuration
|
- name: check R2 configuration
|
||||||
run: sh scripts/upload-r2.sh --check-config
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
env:
|
env:
|
||||||
@@ -27,6 +26,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
|
check-latest: true
|
||||||
- name: Import GPG key
|
- name: Import GPG key
|
||||||
id: import_gpg
|
id: import_gpg
|
||||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||||
@@ -41,11 +41,6 @@ jobs:
|
|||||||
args: release
|
args: release
|
||||||
env:
|
env:
|
||||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
|
||||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
|
||||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
@@ -82,10 +77,10 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
@@ -108,7 +103,7 @@ jobs:
|
|||||||
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
@@ -5,31 +5,40 @@ on:
|
|||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# The runner image ships a stale docker.io login; point docker at an empty config so
|
||||||
|
# image pulls go straight to anonymous instead of attempting (and failing) that auth
|
||||||
|
# first. The path must be a literal: the `runner` context is unavailable in workflow-level
|
||||||
|
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
|
||||||
|
# to ~/.docker with the stale credentials.
|
||||||
|
DOCKER_CONFIG: /tmp/docker-noauth
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: check and test
|
name: check and test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
# The runner image ships a stale docker.io login; point docker at an empty config so
|
|
||||||
# image pulls go straight to anonymous instead of attempting (and failing) that auth
|
|
||||||
# first. The path must be a literal: the `runner` context is unavailable in job-level
|
|
||||||
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
|
|
||||||
# to ~/.docker with the stale credentials.
|
|
||||||
DOCKER_CONFIG: /tmp/docker-noauth
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
|
check-latest: true
|
||||||
- name: prepare anonymous docker config
|
- name: prepare anonymous docker config
|
||||||
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
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`;
|
# 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
|
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
|
||||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||||
- name: pre-pull test images
|
- name: pre-pull test images
|
||||||
|
env:
|
||||||
|
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
|
||||||
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:62ff2089abf5a9ed33bd232895bef5e22f7bb4b200675cec49a5ebc48e3d4ac8 # renovate: datasource=docker
|
||||||
run: |
|
run: |
|
||||||
for img in node:24-bookworm-slim nginx:alpine; do
|
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||||
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
|
for attempt in 1 2 3; do
|
||||||
|
docker pull "$image" && break
|
||||||
|
[ "$attempt" = 3 ] || sleep 5
|
||||||
|
done
|
||||||
|
docker tag "$image" "${image%@*}"
|
||||||
done
|
done
|
||||||
- name: lint
|
- name: lint
|
||||||
run: make lint
|
run: make lint
|
||||||
@@ -44,7 +53,28 @@ jobs:
|
|||||||
# after `make test` so the images it needs are already present on the host daemon.
|
# after `make test` so the images it needs are already present on the host daemon.
|
||||||
- name: test against dind image
|
- name: test against dind image
|
||||||
run: make test-dind
|
run: make test-dind
|
||||||
|
- name: test against podman
|
||||||
|
run: make test-dind TARGET=podman
|
||||||
- name: coverage report
|
- name: coverage report
|
||||||
run: |
|
run: |
|
||||||
make coverage-report
|
make coverage-report
|
||||||
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: gitea compatibility (${{ matrix.gitea_image }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
gitea_image:
|
||||||
|
- gitea/gitea:latest
|
||||||
|
- gitea/gitea:main-nightly
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
|
with:
|
||||||
|
go-version-file: 'go.mod'
|
||||||
|
check-latest: true
|
||||||
|
- name: prepare anonymous docker config
|
||||||
|
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
||||||
|
- name: e2e compatibility
|
||||||
|
run: make test-e2e E2E_GITEA_IMAGE=${{ matrix.gitea_image }}
|
||||||
+8
-6
@@ -46,8 +46,7 @@ linters:
|
|||||||
gocritic:
|
gocritic:
|
||||||
enabled-checks:
|
enabled-checks:
|
||||||
- equalFold
|
- equalFold
|
||||||
disabled-checks:
|
disabled-checks: []
|
||||||
- ifElseChain
|
|
||||||
revive:
|
revive:
|
||||||
severity: error
|
severity: error
|
||||||
rules:
|
rules:
|
||||||
@@ -71,10 +70,14 @@ linters:
|
|||||||
- name: unexported-return
|
- name: unexported-return
|
||||||
- name: var-declaration
|
- name: var-declaration
|
||||||
- name: var-naming
|
- name: var-naming
|
||||||
|
arguments:
|
||||||
|
- [] # AllowList - do not remove as args for the rule are positional and won't work without lists first
|
||||||
|
- [] # DenyList
|
||||||
|
- - skip-initialism-name-checks: true
|
||||||
staticcheck:
|
staticcheck:
|
||||||
checks:
|
checks:
|
||||||
- all
|
- all
|
||||||
- -ST1005
|
testifylint: {}
|
||||||
usetesting:
|
usetesting:
|
||||||
os-temp-dir: true
|
os-temp-dir: true
|
||||||
perfsprint:
|
perfsprint:
|
||||||
@@ -92,8 +95,6 @@ linters:
|
|||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
- comments
|
- comments
|
||||||
- common-false-positives
|
|
||||||
- legacy
|
|
||||||
- std-error-handling
|
- std-error-handling
|
||||||
rules:
|
rules:
|
||||||
- linters:
|
- linters:
|
||||||
@@ -118,7 +119,8 @@ formatters:
|
|||||||
- blank
|
- blank
|
||||||
- default
|
- default
|
||||||
gofumpt:
|
gofumpt:
|
||||||
extra-rules: true
|
extra:
|
||||||
|
group-params: true
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
run:
|
run:
|
||||||
|
|||||||
+7
-19
@@ -83,24 +83,12 @@ builds:
|
|||||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
|
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
|
||||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
|
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
|
||||||
|
|
||||||
blobs:
|
# Uploads every release artifact to Cloudflare R2. The `blobs:` pipe
|
||||||
-
|
# isn't usable here since it authenticates from the global AWS_* env
|
||||||
provider: s3
|
# with no per-entry credentials; `publishers:` supports per-entry
|
||||||
bucket: "{{ .Env.S3_BUCKET }}"
|
# `env:` instead, so it's used to invoke scripts/upload-r2.sh once per
|
||||||
region: "{{ .Env.S3_REGION }}"
|
# artifact. Custom publishers inherit almost nothing from the
|
||||||
directory: "gitea-runner/{{.Version}}"
|
# environment, hence the explicit R2_* forwarding below.
|
||||||
extra_files:
|
|
||||||
- glob: ./**.xz
|
|
||||||
- glob: ./**.sha256
|
|
||||||
|
|
||||||
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
|
||||||
# parallel S3+R2 period (S3 will be removed once migration completes).
|
|
||||||
# A second `blobs:` entry is impossible here since the blob pipe
|
|
||||||
# authenticates from the global AWS_* env with no per-entry
|
|
||||||
# credentials; `publishers:` supports per-entry `env:` instead, so
|
|
||||||
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
|
||||||
# publishers inherit almost nothing from the environment, hence the
|
|
||||||
# explicit R2_* forwarding below.
|
|
||||||
#
|
#
|
||||||
# This publisher fires 109 times for 73 distinct keys because
|
# This publisher fires 109 times for 73 distinct keys because
|
||||||
# goreleaser's release pipe already registers `release.extra_files`
|
# goreleaser's release pipe already registers `release.extra_files`
|
||||||
@@ -125,7 +113,7 @@ publishers:
|
|||||||
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: binary
|
- formats: [binary]
|
||||||
name_template: "{{ .Binary }}"
|
name_template: "{{ .Binary }}"
|
||||||
allow_different_binary_count: true
|
allow_different_binary_count: true
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
||||||
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
|
- Comments: write almost none, short and preferably same-line, explaining why for a future reader. Never narrate code, the change or the prompt. Preserve existing ones that still apply
|
||||||
- Add the current year into the copyright header of new `.go` files
|
- Add the current year into the copyright header of new `.go` files
|
||||||
|
- Read `DEVELOPMENT.md` for internals and conventions
|
||||||
- Ensure no trailing whitespace in edited files
|
- Ensure no trailing whitespace in edited files
|
||||||
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, and `make checks` for the non-lint source checks
|
- Run `make fmt` after `.go` edits, `make tidy` after `go.mod` edits, and `make checks` for the non-lint source checks
|
||||||
- Lint what changed with `make lint-go`, and `make lint-go-windows` for Windows and platform-split files
|
- Lint what changed with `make lint-go`, and `make lint-go-windows` for Windows and platform-split files
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
## Job log line format
|
||||||
|
|
||||||
|
Gitea stores one log row per line and its web UI decodes the payload, so getting the encoding
|
||||||
|
wrong never fails a test here, it only shows up in the browser.
|
||||||
|
|
||||||
|
**A row cannot contain a real newline.** `FormatLog` rewrites `\n` to a literal backslash-n and
|
||||||
|
truncates at 64 KiB on a byte boundary.
|
||||||
|
|
||||||
|
**The payload of a line starting with a recognised prefix is decoded**, with the escape set
|
||||||
|
depending on the prefix:
|
||||||
|
|
||||||
|
| prefix | decodes |
|
||||||
|
| --- | --- |
|
||||||
|
| `##[error]` `##[warning]` `##[notice]` `##[debug]` `##[group]` `##[endgroup]` `##[add-matcher]` | `%25` `%0D` `%0A` `%3B` `%5D` |
|
||||||
|
| `::error::` `::warning::` `::notice::` `::debug::` (with or without ` key=value` properties), `::group::` `::endgroup::` `::add-matcher::` | `%25` `%0D` `%0A` |
|
||||||
|
| `##[command]` `[command]`, or no recognised prefix | nothing |
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- **Emitting a command line?** Escape the payload with `runner.EscapeCommandData`. One escaper
|
||||||
|
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
|
||||||
|
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
|
||||||
|
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
|
||||||
|
- **Forwarding a command from step output?** Leave the payload alone, it arrived escaped and is
|
||||||
|
decoded once. Decoding here double-decodes and destroys multi-line.
|
||||||
|
- **No prefix?** Do not escape, and split multi-line values into one row each.
|
||||||
|
- **Interpolating a secret?** Masking runs after escaping, so `AppendSecretMasker` registers the
|
||||||
|
encoded forms too.
|
||||||
|
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
|
||||||
|
decodes exactly those two when folding a location into an annotation.
|
||||||
|
|
||||||
|
## Built-in actions
|
||||||
|
|
||||||
|
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
|
||||||
|
`act/runner/step_action_builtin.go`.
|
||||||
|
|
||||||
|
## End-to-end compatibility tests
|
||||||
|
|
||||||
|
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
||||||
|
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
|
||||||
|
parallel.
|
||||||
|
|
||||||
|
The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated
|
||||||
|
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
|
||||||
+6
-3
@@ -1,7 +1,7 @@
|
|||||||
### BUILDER STAGE
|
### BUILDER STAGE
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM golang:1.26-alpine3.23 AS builder
|
FROM golang:1.27-alpine3.23 AS builder
|
||||||
|
|
||||||
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
||||||
RUN apk add --no-cache make git
|
RUN apk add --no-cache make git
|
||||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.6.2-dind AS dind
|
FROM docker:29.7.2-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -32,12 +32,14 @@ COPY scripts/s6 /etc/s6
|
|||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|
||||||
|
ENV TINI_SUBREAPER=1
|
||||||
|
|
||||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||||
|
|
||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.6.2-dind-rootless AS dind-rootless
|
FROM docker:29.7.2-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ VOLUME /data
|
|||||||
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
|
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
|
||||||
|
|
||||||
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
|
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
|
||||||
|
ENV TINI_SUBREAPER=1
|
||||||
|
|
||||||
USER rootless
|
USER rootless
|
||||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ GO ?= go
|
|||||||
SHASUM ?= shasum -a 256
|
SHASUM ?= shasum -a 256
|
||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||||
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
|
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
|
||||||
XGO_VERSION := go-1.26.x
|
XGO_VERSION := go-1.27.x
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected] # renovate: datasource=go
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected] # renovate: datasource=go
|
||||||
|
|
||||||
|
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
|
||||||
|
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # renovate: datasource=docker
|
||||||
|
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||||
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
|
|
||||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||||
WINDOWS_ARCHS ?= windows/amd64
|
WINDOWS_ARCHS ?= windows/amd64
|
||||||
@@ -18,8 +23,8 @@ DOCKER_TAG ?= nightly
|
|||||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]2.2 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]3.2 # renovate: datasource=go
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.6.0 # renovate: datasource=go
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go
|
||||||
|
|
||||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
@@ -96,13 +101,8 @@ go-check:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: fmt-check
|
.PHONY: fmt-check
|
||||||
fmt-check: fmt
|
fmt-check: ## check the Go code formatting
|
||||||
@diff=$$(git diff --color=always -- '*.go'); \
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make fmt' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: deps-tools
|
.PHONY: deps-tools
|
||||||
deps-tools: ## install tool dependencies
|
deps-tools: ## install tool dependencies
|
||||||
@@ -137,20 +137,20 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
|||||||
|
|
||||||
.PHONY: security-check
|
.PHONY: security-check
|
||||||
security-check:
|
security-check:
|
||||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
$(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
tidy: ## run go mod tidy
|
tidy: ## run go mod tidy
|
||||||
|
$(eval GO_TOOLCHAIN := $(shell grep -Eo '^toolchain\s+go[0-9.]+' go.mod | cut -d' ' -f2))
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
@# workaround https://github.com/golang/go/issues/75331: restore toolchain if tidy dropped it
|
||||||
|
@if [ -n "$(GO_TOOLCHAIN)" ] && ! grep -qE '^toolchain\s' go.mod; then \
|
||||||
|
$(GO) mod edit -toolchain=$(GO_TOOLCHAIN); \
|
||||||
|
fi
|
||||||
|
|
||||||
.PHONY: tidy-check
|
.PHONY: tidy-check
|
||||||
tidy-check: tidy
|
tidy-check: ## check that go.mod and go.sum are tidy
|
||||||
@diff=$$(git diff --color=always -- go.mod go.sum); \
|
$(GO) mod tidy -diff
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make tidy' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## test everything (integration tests self-skip without docker/network)
|
test: ## test everything (integration tests self-skip without docker/network)
|
||||||
@@ -163,8 +163,14 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
|||||||
@echo "Wrote .tmp/coverage.md"
|
@echo "Wrote .tmp/coverage.md"
|
||||||
|
|
||||||
.PHONY: test-dind
|
.PHONY: test-dind
|
||||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
test-dind: ## run the daemon-facing tests against the built dind image or podman (TARGET=dind|dind-rootless|podman)
|
||||||
@./scripts/test-dind.sh $(TARGET)
|
@PODMAN_TEST_IMAGE=$(PODMAN_TEST_IMAGE) ./scripts/test-dind.sh $(TARGET)
|
||||||
|
|
||||||
|
E2E_CONCURRENCY ?= 8
|
||||||
|
|
||||||
|
.PHONY: test-e2e
|
||||||
|
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
||||||
|
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: $(GOFILES) ## install the runner binary via `go install`
|
install: $(GOFILES) ## install the runner binary via `go install`
|
||||||
|
|||||||
@@ -129,27 +129,64 @@ Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon a
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
The runner reads a YAML file. Without one, every option keeps its default.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gitea-runner generate-config > config.yaml
|
./gitea-runner config init # write config.yaml, with no option set
|
||||||
|
./gitea-runner config generate | less # read what the options do
|
||||||
|
./gitea-runner -c config.yaml daemon # -c also works on register and cache-server
|
||||||
```
|
```
|
||||||
|
|
||||||
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`):
|
`config generate` prints [config.example.yaml](internal/pkg/config/config.example.yaml). Every value in it is commented out, so copy the lines you want to change into your own file and uncomment them.
|
||||||
|
|
||||||
|
#### Editing a config file
|
||||||
|
|
||||||
|
`config` edits a file in place, which is handy in provisioning scripts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gitea-runner -c config.yaml register
|
./gitea-runner config set runner.capacity 4
|
||||||
./gitea-runner -c config.yaml daemon
|
./gitea-runner config set runner.timeout 90m # written as 1h30m0s
|
||||||
./gitea-runner -c config.yaml cache-server
|
./gitea-runner config set runner.envs.MY_VAR value
|
||||||
|
./gitea-runner config add runner.labels 'ubuntu:docker://node:22'
|
||||||
|
./gitea-runner config remove runner.labels 'ubuntu:docker://node:22'
|
||||||
|
./gitea-runner config get runner.labels
|
||||||
```
|
```
|
||||||
|
|
||||||
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `generate-config` prints).
|
A key is its dotted YAML path. An unknown key, a value of the wrong type, or `add`/`remove` on anything but a list is refused before the file is touched. `set` replaces a whole list when you give it several values.
|
||||||
|
|
||||||
#### Without a config file
|
An edit keeps the comments and the key order of the file. Indentation becomes two spaces, and a blank line between two values is dropped.
|
||||||
|
|
||||||
If you omit `-c`, built-in defaults apply (same as an empty YAML document).
|
`config get`, `set`, `add` and `remove` use `config.yaml` (or `config.yml`) from the working directory, then from the directory of the binary, and print their choice to stderr. `config init` writes `config.yaml` in the working directory, and refuses to overwrite an existing config without `--force`. Pass `-c` for another path.
|
||||||
|
|
||||||
Earlier releases let a small set of environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the default config. Those overrides have been removed — use a YAML config file for all settings instead. For the Docker images, the entrypoint still understands a separate set of variables (such as `RUNNER_STATE_FILE`); see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
#### Tool cache
|
||||||
|
|
||||||
|
Setup actions like `setup-go` install tools into `RUNNER_TOOL_CACHE`, which is `/opt/hostedtoolcache` inside a job. `runner.tool_cache_mode` selects what backs it:
|
||||||
|
|
||||||
|
| Mode | Tool cache | Trade-off |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `none` (default) | Per job, provided by the job image | A version the image lacks is downloaded in every job |
|
||||||
|
| `shared` | One volume reused by every job | Two jobs writing the same tool version at once corrupt it, so use it only with `runner.capacity: 1` |
|
||||||
|
|
||||||
|
With `none`, tools must come from the job image. Install them into `/opt/hostedtoolcache/<tool>/<version>/<arch>`, with an empty `<arch>.complete` file next to the directory:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN GO=$(curl -fsSL 'https://go.dev/dl/?mode=json' | grep -oP '"version": "\Kgo1\.26\.[0-9]*' | head -1); \
|
||||||
|
DIR="/opt/hostedtoolcache/go/${GO#go}/x64" && \
|
||||||
|
mkdir -p "$(dirname "$DIR")" && \
|
||||||
|
curl -fsSL "https://dl.google.com/go/${GO}.linux-amd64.tar.gz" | tar -xz -C /tmp && \
|
||||||
|
mv /tmp/go "$DIR" && \
|
||||||
|
touch "${DIR}.complete"
|
||||||
|
```
|
||||||
|
|
||||||
|
A workflow requesting a minor version, `go-version: "1.26"`, resolves to the newest matching version in the cache, so a patch update in the image still hits it.
|
||||||
|
|
||||||
|
Of the [runner images](https://gitea.com/gitea/runner-images), the `-full` flavour is the one that ships tools in this layout.
|
||||||
|
|
||||||
|
`gitea-runner exec` reads no config file and takes `--tool-cache-mode` instead, defaulting to `none`.
|
||||||
|
|
||||||
|
#### Environment variables
|
||||||
|
|
||||||
|
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||||
|
|
||||||
### Labels
|
### Labels
|
||||||
|
|
||||||
@@ -191,7 +228,7 @@ a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubunt
|
|||||||
|
|
||||||
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
||||||
|
|
||||||
If a job's `runs-on` matches none of the runner's labels, the job still runs, in the default `docker.gitea.com/runner-images:ubuntu-latest` image. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||||
|
|
||||||
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
||||||
|
|
||||||
@@ -209,6 +246,37 @@ Whenever the resulting labels differ from the ones in the registration file, the
|
|||||||
|
|
||||||
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
|
> **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker.
|
||||||
|
|
||||||
|
#### Service containers
|
||||||
|
|
||||||
|
A job's `services` are started before its steps run. When a service's image or its `options` declare a healthcheck, the runner waits for it to report healthy, so a workflow does not have to poll for its own services:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 5s
|
||||||
|
--health-retries 10
|
||||||
|
```
|
||||||
|
|
||||||
|
A service that reports unhealthy fails the job right away, with its container log. One that never becomes healthy fails it after `container.service_ready_timeout` (default `5m`, negative disables the wait). A service that exits without declaring a healthcheck only gets its log and a warning.
|
||||||
|
|
||||||
|
A job in a container reaches a service by its id on the job network, on the port the service listens on, for example `psql -h postgres -p 5432`. The started containers also fill the `job` context: `job.container.{id,network}` and `job.services.<id>.{id,network,ports}`, where `ports` maps a container port to the host port Docker published it on, for the services that publish one.
|
||||||
|
|
||||||
|
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
|
||||||
|
|
||||||
|
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
|
||||||
|
|
||||||
|
Containers a job starts through the Docker socket can bind-mount the workspace by the job's own path, as on a host, for example `docker run -v "$PWD:/src"`. Without the Docker proxy, for example with a remote `DOCKER_HOST`, use `GITEA_DOCKER_WORKSPACE`, the path the daemon sees, as the prefix of workspace binds, with `.` as the fallback for local use:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. The proxy removes containers, networks and volumes after post steps and the completed hook. Named volumes created through it are job-scoped. To retain resources, mount the daemon socket explicitly in `container.options`. Host jobs use their existing Docker access, where `.` works.
|
||||||
|
|
||||||
#### Proxy
|
#### Proxy
|
||||||
|
|
||||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||||
@@ -223,7 +291,7 @@ The runner uses them for its own requests and gives them to every job, in lower
|
|||||||
|
|
||||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||||
|
|
||||||
- the cache server
|
- the built-in cache server, whose address is assigned at startup
|
||||||
- `localhost`, `127.0.0.1` and `::1`
|
- `localhost`, `127.0.0.1` and `::1`
|
||||||
- the job's service containers
|
- the job's service containers
|
||||||
- the Docker daemon, when it is reached over `tcp://`
|
- the Docker daemon, when it is reached over `tcp://`
|
||||||
@@ -238,55 +306,68 @@ Dockerfile actions are built with these variables as build arguments, so their `
|
|||||||
|
|
||||||
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
||||||
|
|
||||||
|
#### Built-in actions (`uses: builtin:<name>`)
|
||||||
|
|
||||||
|
Use `uses: builtin:checkout` to check out a repository without downloading `actions/checkout` or needing Node in the job image, see **[docs/builtin-actions.md](docs/builtin-actions.md)**.
|
||||||
|
|
||||||
#### Caching (`actions/cache`)
|
#### Caching (`actions/cache`)
|
||||||
|
|
||||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
Each runner starts its own cache server, so runners do not share cached entries. A runner in a container on the jobs' Docker daemon attaches itself to each job network, so jobs reach its cache without extra setup.
|
||||||
|
|
||||||
**Cache service v2**
|
**Sharing a cache between runners**
|
||||||
|
|
||||||
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
Run one `gitea-runner cache-server` and point every runner at it, using the same secret everywhere, for example from `openssl rand -hex 32`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# cache-server.yaml
|
||||||
|
cache:
|
||||||
|
dir: /data/actcache
|
||||||
|
port: 8088
|
||||||
|
external_secret: "<secret>" # or external_secret_file: /path/to/secret
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gitea-runner -c cache-server.yaml cache-server
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# each runner's config
|
||||||
|
cache:
|
||||||
|
external_server: "http://<cache-server-host>:8088/"
|
||||||
|
external_secret: "<secret>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--dir`, `--host` and `--port` override the matching `cache` keys. Eviction settings take effect on the cache server, not on the runners.
|
||||||
|
|
||||||
|
Runners can also share one `cache.dir` on a file system with working file locks, at the cost of slower cache requests.
|
||||||
|
|
||||||
|
**Storing the cache in an S3-compatible bucket**
|
||||||
|
|
||||||
|
The cache can also be stored in an S3-compatible bucket such as AWS S3, MinIO or RustFS, with no cache server. Runners with the same settings use the same cache:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cache:
|
cache:
|
||||||
v2: false
|
s3:
|
||||||
|
endpoint: "http://minio:9000"
|
||||||
|
region: "us-east-1"
|
||||||
|
bucket: "runner-cache"
|
||||||
|
prefix: "gitea-runner-cache"
|
||||||
|
path_style: true
|
||||||
|
access_key_file: "/run/secrets/cache-s3-access-key"
|
||||||
|
secret_key_file: "/run/secrets/cache-s3-secret-key"
|
||||||
```
|
```
|
||||||
|
|
||||||
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle and keeps the untouched copy beside it; a bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork.
|
A cache saved on one runner can be restored on the others within 30 seconds. The eviction settings below do not apply to the bucket, so add lifecycle rules to it that expire old caches and abort incomplete multipart uploads.
|
||||||
|
|
||||||
**Shared cache across multiple runners**
|
**Eviction**
|
||||||
|
|
||||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
Entries not read or written within `retention` (default `168h`) are removed. A repository over `repo_size_limit` (default `10GB`) loses its least recently used entries, and `size_limit` (off by default) caps the whole cache the same way. Entries in use are never removed. The cache also keeps 1024 MiB free on its volume, or `health_check.min_free_disk_space_mb` when health checks are enabled. See [config.example.yaml](internal/pkg/config/config.example.yaml) for all options.
|
||||||
|
|
||||||
1. Create a config file for the cache server host:
|
**Cache service v2**
|
||||||
|
|
||||||
```yaml
|
`actions/cache` v3.4.0, v4.2.0 and later use the cache service v2 API, which the runner serves by default. These actions fall back to v1 on hosts they do not recognize as GitHub, so the runner removes that check from them while a job runs. This also lets the stock `actions/upload-artifact` v4.4.0 and `actions/download-artifact` v4.1.8 and later work without the `gitea-upload-artifact` fork. With `runner.patch_actions: false`, the cache stays on v1 and the artifact actions fail.
|
||||||
cache:
|
|
||||||
dir: /data/actcache
|
|
||||||
port: 8088
|
|
||||||
external_secret: "replace-with-a-strong-random-secret"
|
|
||||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the server:
|
With v2, artifact calls also go through the cache server, so jobs must be able to reach it. `cache.v2: false` sends them to Gitea directly, unless the Gitea URL has a path or `runner.insecure` is set with HTTPS.
|
||||||
|
|
||||||
```bash
|
|
||||||
gitea-runner -c cache-server-config.yaml cache-server
|
|
||||||
```
|
|
||||||
|
|
||||||
3. On every runner:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
cache:
|
|
||||||
external_server: "http://<cache-server-host>:8088/"
|
|
||||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
|
||||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
|
||||||
|
|
||||||
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
|
||||||
|
|
||||||
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
|
|
||||||
|
|
||||||
#### Official Docker image
|
#### Official Docker image
|
||||||
|
|
||||||
@@ -321,6 +402,20 @@ Both hooks are synchronous and block the job while they run. Either one exiting
|
|||||||
|
|
||||||
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||||
|
|
||||||
|
#### OpenTelemetry (`OTEL_*`)
|
||||||
|
|
||||||
|
Set `OTEL_EXPORTER_OTLP_ENDPOINT` in the runner's environment to send a trace of every job to an OpenTelemetry collector. See **[docs/telemetry.md](docs/telemetry.md)**.
|
||||||
|
|
||||||
|
#### Local job logs (`log.job.dir`)
|
||||||
|
|
||||||
|
Set `log.job.dir` to a path and the runner writes a copy of every task's log there as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same secrets masked and the job's result on the last line. Off by default, and what Gitea shows does not change.
|
||||||
|
|
||||||
|
#### Secret masking
|
||||||
|
|
||||||
|
A job's secrets and its `::add-mask::` values are hidden from what the runner writes and uploads: the job log, the local copy above, job summaries, and the names of the containers it creates. A job output carrying one is skipped with a warning rather than sent masked, as GitHub does, so a downstream `needs.<job>.outputs.<name>` reading it is empty.
|
||||||
|
|
||||||
|
`log.job.retention` (default `168h`) is how long a log is kept, expired ones being deleted as new tasks start, and `log.job.max_size` (default `1GB`) caps one log. Keep `retention` above `runner.timeout` so a long job cannot outlive its own log, and prefer local disk, the file is written while the job runs. Only the runner's own user can read it.
|
||||||
|
|
||||||
### Example Deployments
|
### Example Deployments
|
||||||
|
|
||||||
Check out the [examples](examples) directory for sample deployment types.
|
Check out the [examples](examples) directory for sample deployment types.
|
||||||
+501
-131
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,8 @@ package artifactcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"encoding/json"
|
"encoding/json/jsontext"
|
||||||
|
"encoding/json/v2"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -38,8 +39,9 @@ const (
|
|||||||
|
|
||||||
blobUploadURLTTL = time.Hour
|
blobUploadURLTTL = time.Hour
|
||||||
|
|
||||||
// twirpInternal is the only error code that is not the client's fault.
|
|
||||||
twirpInternal = "internal"
|
twirpInternal = "internal"
|
||||||
|
twirpUnavailable = "unavailable"
|
||||||
|
twirpUnauthenticated = "unauthenticated"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||||
@@ -55,7 +57,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Key == "" || req.Version == "" {
|
if req.Key == "" || req.Version == "" {
|
||||||
@@ -77,27 +79,47 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
|
h.touch(db, existing) // the client skips the upload, so this is the only sign the entry is still in use
|
||||||
h.twirpNotOK(w, r)
|
h.twirpNotOK(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A second live reservation is finalized by whichever job calls last, against the other's upload.
|
||||||
|
owner := hashedToken(bearerToken(r))
|
||||||
|
if pending, err := findExactCache(db, cred.Repo, req.Key, req.Version, false); err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
|
||||||
|
if pending.Owner != owner {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.touch(db, pending) // still uploading, so it must not go stale under the sweep
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{ // this job retrying its own reservation
|
||||||
|
"ok": true,
|
||||||
|
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, pending.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
cache := &Cache{
|
cache := &Cache{
|
||||||
Repo: cred.Repo,
|
Repo: cred.Repo,
|
||||||
Key: req.Key,
|
Key: req.Key,
|
||||||
Version: req.Version,
|
Version: req.Version,
|
||||||
|
Owner: owner,
|
||||||
Size: -1, // the size is only known at finalize time
|
Size: -1, // the size is only known at finalize time
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UsedAt: now,
|
UsedAt: now,
|
||||||
}
|
}
|
||||||
if err := insertCache(db, cache); err != nil {
|
if err := h.insertCache(db, cache); err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"signed_upload_url": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +127,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +139,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||||
|
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
|
||||||
|
cache = nil // not the reservation this job made, so not this job's to commit
|
||||||
|
}
|
||||||
|
if err == nil && cache == nil {
|
||||||
|
// A retry whose first response was lost finds the entry already committed.
|
||||||
|
cache, err = findExactCache(db, cred.Repo, req.Key, req.Version, true)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
@@ -125,14 +154,16 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
h.twirpNotOK(w, r)
|
h.twirpNotOK(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.Close() // commitCache needs the store closed
|
|
||||||
|
|
||||||
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
if !cache.Complete {
|
||||||
|
db.Close() // commitCache needs the store closed
|
||||||
|
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
|
||||||
if err := h.commitCache(cache); err != nil {
|
if err := h.commitCache(cache); err != nil {
|
||||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||||
h.twirpNotOK(w, r)
|
h.twirpNotOK(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -145,7 +176,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +199,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"signed_download_url": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
|
"signed_download_url": h.signedArtifactURL(cred, cache.ID, time.Now().Add(artifactURLTTL)),
|
||||||
"matched_key": cache.Key,
|
"matched_key": cache.Key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -228,8 +259,13 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||||
status := http.StatusBadRequest
|
status := http.StatusBadRequest
|
||||||
if code == twirpInternal {
|
switch code {
|
||||||
|
case twirpInternal:
|
||||||
status = http.StatusInternalServerError
|
status = http.StatusInternalServerError
|
||||||
|
case twirpUnavailable:
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
case twirpUnauthenticated:
|
||||||
|
status = http.StatusUnauthorized
|
||||||
}
|
}
|
||||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -246,8 +282,8 @@ type (
|
|||||||
v2FinalizeRequest struct {
|
v2FinalizeRequest struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
SizeBytes json.Number `json:"size_bytes"`
|
SizeBytes twirpInt64 `json:"size_bytes"`
|
||||||
SizeBytesCamel json.Number `json:"sizeBytes"`
|
SizeBytesCamel twirpInt64 `json:"sizeBytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
v2DownloadRequest struct {
|
v2DownloadRequest struct {
|
||||||
@@ -258,6 +294,31 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// twirpInt64 accepts its value as the JSON string the mapping prescribes or as a bare number.
|
||||||
|
type twirpInt64 int64
|
||||||
|
|
||||||
|
func (n *twirpInt64) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
||||||
|
val, err := dec.ReadValue()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
digits := []byte(val)
|
||||||
|
switch val.Kind() {
|
||||||
|
case 'n': // absent, keep the zero value
|
||||||
|
return nil
|
||||||
|
case '"':
|
||||||
|
if digits, err = jsontext.AppendUnquote(nil, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(string(digits), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*n = twirpInt64(parsed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d v2DownloadRequest) keys() []string {
|
func (d v2DownloadRequest) keys() []string {
|
||||||
restoreKeys := d.RestoreKeys
|
restoreKeys := d.RestoreKeys
|
||||||
if len(restoreKeys) == 0 {
|
if len(restoreKeys) == 0 {
|
||||||
@@ -268,6 +329,6 @@ func (d v2DownloadRequest) keys() []string {
|
|||||||
|
|
||||||
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
||||||
var req T
|
var req T
|
||||||
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
|
err := json.UnmarshalRead(io.LimitReader(r.Body, 1<<20), &req)
|
||||||
return req, err
|
return req, err
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,13 @@ package artifactcache
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -32,7 +33,7 @@ func v2Call(t *testing.T, handler *Handler, client *http.Client, method string,
|
|||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
got := map[string]any{}
|
got := map[string]any{}
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
return got
|
return got
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +67,6 @@ func getURL(t *testing.T, url string) []byte {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTestHandler(t *testing.T) *Handler {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = handler.Close() })
|
|
||||||
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
|
||||||
return handler
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
|
||||||
// with the upload URL it used.
|
// with the upload URL it used.
|
||||||
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
|
||||||
@@ -97,7 +88,7 @@ func saveV2(t *testing.T, handler *Handler, key, version string, content []byte)
|
|||||||
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
|
||||||
// or to replace a finalized entry.
|
// or to replace a finalized entry.
|
||||||
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
||||||
handler := startTestHandler(t)
|
handler := newTestHandler(t, Policy{})
|
||||||
content := []byte("the cached archive")
|
content := []byte("the cached archive")
|
||||||
|
|
||||||
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||||
@@ -126,7 +117,7 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
|
|||||||
// A large archive is staged as blocks and only put in order by the final block list, so
|
// A large archive is staged as blocks and only put in order by the final block list, so
|
||||||
// blocks that arrive out of order must still be assembled the way the client asked.
|
// blocks that arrive out of order must still be assembled the way the client asked.
|
||||||
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||||
handler := startTestHandler(t)
|
handler := newTestHandler(t, Policy{})
|
||||||
|
|
||||||
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
|
||||||
uploadURL, _ := created["signed_upload_url"].(string)
|
uploadURL, _ := created["signed_upload_url"].(string)
|
||||||
@@ -151,6 +142,17 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
list.WriteString(`</BlockList>`)
|
list.WriteString(`</BlockList>`)
|
||||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()),
|
||||||
|
"a client that lost the first answer retries the list it already sent")
|
||||||
|
|
||||||
|
var reordered bytes.Buffer
|
||||||
|
reordered.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||||
|
for _, blockID := range []string{order[1], order[0], order[2]} {
|
||||||
|
fmt.Fprintf(&reordered, "<Latest>%s</Latest>", blockID)
|
||||||
|
}
|
||||||
|
reordered.WriteString(`</BlockList>`)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, putBlob(t, uploadURL+"&comp=blocklist", reordered.Bytes()),
|
||||||
|
"the blocks are already assembled, so a different order would silently disagree with them")
|
||||||
|
|
||||||
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||||
@@ -163,7 +165,7 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheServiceV2Lookups(t *testing.T) {
|
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||||
handler := startTestHandler(t)
|
handler := newTestHandler(t, Policy{})
|
||||||
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||||
require.Equal(t, true, saved["ok"])
|
require.Equal(t, true, saved["ok"])
|
||||||
|
|
||||||
@@ -198,6 +200,18 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
assert.NotEmpty(t, reserved["signed_upload_url"])
|
assert.NotEmpty(t, reserved["signed_upload_url"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("a proxied job is handed the address its runner registered", func(t *testing.T) {
|
||||||
|
const proxy = "https://cache.example.invalid"
|
||||||
|
handler.RegisterJob("proxied", JobCredential{Repo: testRepo, PublicURL: proxy + "/"})
|
||||||
|
client := &http.Client{Transport: &bearerTransport{token: "proxied"}}
|
||||||
|
|
||||||
|
created := v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "proxied-key", "version": "v1"})
|
||||||
|
assert.True(t, strings.HasPrefix(created["signed_upload_url"].(string), proxy+blobPath+"/"))
|
||||||
|
|
||||||
|
got := v2Call(t, handler, client, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
assert.True(t, strings.HasPrefix(got["signed_download_url"].(string), proxy+apiPath+"/artifacts/"))
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
|
||||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
||||||
@@ -225,7 +239,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
got := map[string]any{}
|
got := map[string]any{}
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
assert.Equal(t, "deps-abc", got["cacheKey"])
|
assert.Equal(t, "deps-abc", got["cacheKey"])
|
||||||
assert.NotEmpty(t, got["archiveLocation"])
|
assert.NotEmpty(t, got["archiveLocation"])
|
||||||
})
|
})
|
||||||
@@ -239,3 +253,101 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
assert.Equal(t, false, got["ok"])
|
assert.Equal(t, false, got["ok"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
|
||||||
|
handler := newTestHandler(t, Policy{})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
body string
|
||||||
|
status int
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"no bearer at all", "", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||||
|
{"a bearer nobody registered", "not-a-job", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||||
|
{"a body that is not the request", testToken, `{`, http.StatusBadRequest, "malformed"},
|
||||||
|
{"a request missing its key", testToken, `{"version":"v"}`, http.StatusBadRequest, "invalid_argument"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", strings.NewReader(tt.body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tt.token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, tt.status, resp.StatusCode)
|
||||||
|
got := map[string]string{}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
|
assert.Equal(t, tt.code, got["code"])
|
||||||
|
assert.NotEmpty(t, got["msg"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
|
||||||
|
handler := newTestHandler(t, Policy{})
|
||||||
|
content := []byte("the cached archive")
|
||||||
|
|
||||||
|
const otherJob = "another-jobs-token"
|
||||||
|
defer handler.RegisterJob(otherJob, JobCredential{Repo: testRepo})()
|
||||||
|
reserve := func(client *http.Client) map[string]any {
|
||||||
|
return v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "shared", "version": "v1"})
|
||||||
|
}
|
||||||
|
first := reserve(testClient)
|
||||||
|
require.Equal(t, true, first["ok"])
|
||||||
|
retry := reserve(testClient)
|
||||||
|
assert.Equal(t, true, retry["ok"], "a job retrying its own reservation is not a conflict")
|
||||||
|
entryOf := func(reserved map[string]any) string {
|
||||||
|
parsed, err := url.Parse(reserved["signed_upload_url"].(string))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return parsed.Path
|
||||||
|
}
|
||||||
|
assert.Equal(t, entryOf(first), entryOf(retry), "the same reservation, however freshly its URL is signed")
|
||||||
|
assert.Equal(t, false, reserve(&http.Client{Transport: &bearerTransport{token: otherJob}})["ok"],
|
||||||
|
"another job's reservation would be finalized against the first upload")
|
||||||
|
|
||||||
|
otherClient := &http.Client{Transport: &bearerTransport{token: otherJob}}
|
||||||
|
reserved := v2Call(t, handler, otherClient, "CreateCacheEntry", map[string]any{"key": "owned", "version": "v1"})
|
||||||
|
require.Equal(t, true, reserved["ok"])
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, reserved["signed_upload_url"].(string), content))
|
||||||
|
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||||
|
map[string]any{"key": "owned", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||||
|
"another job's upload is not this job's to finalize")
|
||||||
|
|
||||||
|
base := handler.ExternalURL() + apiPath
|
||||||
|
reserveV1, err := json.Marshal(&Request{Key: "v1-made", Version: "v1", Size: int64(len(content))})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(reserveV1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var madeByV1 struct {
|
||||||
|
CacheID uint64 `json:"cacheId"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &madeByV1))
|
||||||
|
resp.Body.Close()
|
||||||
|
upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
|
||||||
|
fmt.Sprintf("%s/caches/%d", base, madeByV1.CacheID), bytes.NewReader(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
|
||||||
|
resp, err = testClient.Do(upload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||||
|
map[string]any{"key": "v1-made", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||||
|
"a reservation this job did not make through v2 is not its to commit")
|
||||||
|
|
||||||
|
finalized, _ := saveV2(t, handler, "deps", "v1", content)
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
again := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "deps", "version": "v1", "size_bytes": strconv.Itoa(len(content)),
|
||||||
|
})
|
||||||
|
assert.Equal(t, true, again["ok"], "a retry whose first answer was lost must not report a failed save")
|
||||||
|
assert.Equal(t, finalized["entry_id"], again["entry_id"])
|
||||||
|
}
|
||||||
@@ -10,29 +10,13 @@ type Request struct {
|
|||||||
Size int64 `json:"cacheSize"`
|
Size int64 `json:"cacheSize"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Request) ToCache() *Cache {
|
|
||||||
if c == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ret := &Cache{
|
|
||||||
Key: c.Key,
|
|
||||||
Version: c.Version,
|
|
||||||
Size: c.Size,
|
|
||||||
}
|
|
||||||
if c.Size == 0 {
|
|
||||||
// So the request comes from old versions of actions, like `actions/cache@v2`.
|
|
||||||
// It doesn't send cache size. Set it to -1 to indicate that.
|
|
||||||
ret.Size = -1
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
ID uint64 `json:"id" boltholdKey:"ID"`
|
ID uint64 `json:"id" boltholdKey:"ID"`
|
||||||
Repo string `json:"repo" boltholdIndex:"Repo"`
|
Repo string `json:"repo" boltholdIndex:"Repo"`
|
||||||
Key string `json:"key" boltholdIndex:"Key"`
|
Key string `json:"key" boltholdIndex:"Key"`
|
||||||
Version string `json:"version" boltholdIndex:"Version"`
|
Version string `json:"version" boltholdIndex:"Version"`
|
||||||
Size int64 `json:"cacheSize"`
|
Size int64 `json:"cacheSize"`
|
||||||
|
Owner string `json:"owner"` // hashed bearer, so a job's own retry is not another job's reservation
|
||||||
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
||||||
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
||||||
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package artifactcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -15,23 +17,38 @@ import (
|
|||||||
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
|
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
|
||||||
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
|
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
|
||||||
// this runner cannot patch need, docker buildx among them.
|
// this runner cannot patch need, docker buildx among them.
|
||||||
//
|
|
||||||
// The instance to forward to travels with the job registration rather than with configuration, so
|
|
||||||
// a cache server shared between runners serves each of their instances.
|
|
||||||
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
|
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
|
||||||
|
|
||||||
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
||||||
// registered with, and the 404 the router would have written otherwise.
|
// registered with, and the 404 the router would have written otherwise.
|
||||||
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||||
cred, ok := h.lookupCredential(bearerToken(r))
|
if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||||
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cred, ok := h.lookupCredential(bearerToken(r))
|
||||||
|
if !ok {
|
||||||
|
h.twirpError(w, r, twirpUnauthenticated, errors.New("unknown bearer token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cred.Results == "" {
|
||||||
|
h.twirpError(w, r, twirpInternal, errors.New("no instance is registered for this job"))
|
||||||
|
return
|
||||||
|
}
|
||||||
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
||||||
|
if err == nil && (target.Hostname() == "" || (target.Scheme != "http" && target.Scheme != "https")) {
|
||||||
|
err = errors.New("not an absolute http or https URL the job could use")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
h.twirpError(w, r, twirpInternal, fmt.Errorf("artifact service address %q is unusable: %w", cred.Results, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.mustProxy(r, cred, target) {
|
||||||
|
redirect := target.JoinPath(r.URL.EscapedPath())
|
||||||
|
redirect.RawQuery = mergeQuery(target.RawQuery, r.URL.RawQuery)
|
||||||
|
h.logger.Debugf("%s %s: redirecting to %s", r.Method, r.URL.Path, target)
|
||||||
|
http.Redirect(w, r, redirect.String(), http.StatusTemporaryRedirect)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
||||||
@@ -44,9 +61,9 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
|||||||
// directly and never through here.
|
// directly and never through here.
|
||||||
r.Out.Host = target.Host
|
r.Out.Host = target.Host
|
||||||
},
|
},
|
||||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if cred.InsecureTLS {
|
if cred.InsecureTLS {
|
||||||
@@ -55,5 +72,24 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A protocol switch loses the bearer or the agent, and an untrusted instance needs skipped verification.
|
||||||
|
func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL) bool {
|
||||||
|
if cred.InsecureTLS && target.Scheme == "https" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
return scheme != target.Scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeQuery(target, request string) string {
|
||||||
|
if target == "" || request == "" {
|
||||||
|
return target + request
|
||||||
|
}
|
||||||
|
return target + "&" + request
|
||||||
|
}
|
||||||
|
|
||||||
// insecureTransport is shared, because a transport per request would pool no connections.
|
// insecureTransport is shared, because a transport per request would pool no connections.
|
||||||
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
var insecureTransport = &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"encoding/json/v2"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -14,8 +16,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
|
|
||||||
// still point at Gitea, and nothing else is proxied.
|
|
||||||
func TestFrontResultsService(t *testing.T) {
|
func TestFrontResultsService(t *testing.T) {
|
||||||
var gotHost, gotPath, gotProto string
|
var gotHost, gotPath, gotProto string
|
||||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -23,34 +23,112 @@ func TestFrontResultsService(t *testing.T) {
|
|||||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||||
}))
|
}))
|
||||||
defer gitea.Close()
|
defer gitea.Close()
|
||||||
|
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
|
||||||
|
defer selfSigned.Close()
|
||||||
|
|
||||||
handler, err := StartHandler(t.TempDir(), "127.0.0.1", 0, "", nil)
|
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
const token = "forward-token"
|
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
|
||||||
|
|
||||||
client := &http.Client{Transport: &bearerTransport{token: token}}
|
noRedirect := func(token string) *http.Client {
|
||||||
post := func(path string) int {
|
return &http.Client{
|
||||||
|
Transport: &bearerTransport{token: token},
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post := func(path string) (int, map[string]string) {
|
||||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := client.Do(req)
|
resp, err := noRedirect(token).Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return resp.StatusCode
|
body := map[string]string{}
|
||||||
|
_ = json.UnmarshalRead(resp.Body, &body)
|
||||||
|
return resp.StatusCode, body
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
status, body := post(rpc)
|
||||||
"an unregistered token is forwarded nowhere")
|
assert.Equal(t, http.StatusUnauthorized, status, "an unregistered token is told so, not left to parse a 404 page")
|
||||||
|
assert.Equal(t, twirpUnauthenticated, body["code"])
|
||||||
|
assert.Contains(t, body["msg"], "bearer")
|
||||||
|
assert.NotEmpty(t, body["msg"])
|
||||||
|
|
||||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})()
|
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: selfSigned.URL, InsecureTLS: true})()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact"))
|
status, _ = post(rpc)
|
||||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
require.Equal(t, http.StatusOK, status, "an http job reaching an https instance must proxy")
|
||||||
|
assert.Equal(t, strings.TrimPrefix(selfSigned.URL, "https://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||||
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
||||||
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
assert.Equal(t, rpc, gotPath)
|
||||||
|
|
||||||
gotPath = ""
|
gotPath = ""
|
||||||
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do"))
|
status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
|
||||||
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo"))
|
assert.Equal(t, http.StatusNotFound, status)
|
||||||
|
status, _ = post("/api/v1/repos/owner/repo")
|
||||||
|
assert.Equal(t, http.StatusNotFound, status)
|
||||||
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cred JobCredential
|
||||||
|
forwardedProto string
|
||||||
|
reqQuery string
|
||||||
|
want int
|
||||||
|
location string
|
||||||
|
wantMsg string
|
||||||
|
}{
|
||||||
|
{name: "an instance the job reaches itself", cred: JobCredential{Results: gitea.URL}, want: http.StatusTemporaryRedirect},
|
||||||
|
{name: "a sub-path instance keeps its prefix", cred: JobCredential{Results: gitea.URL + "/sub"}, want: http.StatusTemporaryRedirect},
|
||||||
|
{name: "an instance with no certificate to distrust", cred: JobCredential{Results: gitea.URL, InsecureTLS: true}, want: http.StatusTemporaryRedirect},
|
||||||
|
{
|
||||||
|
name: "both queries survive", cred: JobCredential{Results: gitea.URL + "?tenant=one"}, reqQuery: "?run=7",
|
||||||
|
want: http.StatusTemporaryRedirect, location: gitea.URL + rpc + "?tenant=one&run=7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a trusted https instance is reached by the job itself",
|
||||||
|
cred: JobCredential{Results: selfSigned.URL}, forwardedProto: "https", want: http.StatusTemporaryRedirect,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the job does not share this server's disregard for the certificate",
|
||||||
|
cred: JobCredential{Results: selfSigned.URL, InsecureTLS: true}, forwardedProto: "https", want: http.StatusOK,
|
||||||
|
},
|
||||||
|
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
|
||||||
|
{
|
||||||
|
name: "a forwarded https scheme onto an http instance is proxied",
|
||||||
|
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a scheme the job could not use is refused before it is sent one",
|
||||||
|
cred: JobCredential{Results: "ftp://gitea.example"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an address with no host is refused before it is sent one",
|
||||||
|
cred: JobCredential{Results: "http://:3000"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
defer handler.RegisterJob(tt.name, tt.cred)()
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+rpc+tt.reqQuery, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.forwardedProto != "" {
|
||||||
|
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||||
|
}
|
||||||
|
resp, err := noRedirect(tt.name).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, tt.want, resp.StatusCode)
|
||||||
|
switch tt.want {
|
||||||
|
case http.StatusTemporaryRedirect:
|
||||||
|
assert.Equal(t, cmp.Or(tt.location, tt.cred.Results+rpc), resp.Header.Get("Location"))
|
||||||
|
case http.StatusInternalServerError:
|
||||||
|
body := map[string]string{}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &body))
|
||||||
|
assert.Equal(t, twirpInternal, body["code"])
|
||||||
|
assert.Contains(t, body["msg"], cmp.Or(tt.wantMsg, tt.cred.Results))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json/v2"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// S3Options configures the bucket that stores completed cache entries.
|
||||||
|
type S3Options struct {
|
||||||
|
Endpoint string
|
||||||
|
Region string
|
||||||
|
Bucket string
|
||||||
|
Prefix string
|
||||||
|
PathStyle *bool // nil uses virtual-hosted requests on AWS and path-style ones elsewhere
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheStorage interface {
|
||||||
|
Exist(uint64) (bool, error)
|
||||||
|
Write(uint64, int64, io.Reader) error
|
||||||
|
WriteBlock(uint64, string, io.Reader) error
|
||||||
|
OrderBlocks(uint64, []string) error
|
||||||
|
Commit(uint64, int64) (int64, error)
|
||||||
|
Serve(http.ResponseWriter, *http.Request, uint64)
|
||||||
|
Remove(uint64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3PartSize = 5 << 30 // the largest part S3 accepts
|
||||||
|
|
||||||
|
// s3Storage stages uploads on disk and keeps completed entries and their metadata in a bucket.
|
||||||
|
type s3Storage struct {
|
||||||
|
*Storage
|
||||||
|
client *http.Client
|
||||||
|
bucketURL *url.URL
|
||||||
|
opts S3Options
|
||||||
|
}
|
||||||
|
|
||||||
|
type s3Part struct {
|
||||||
|
PartNumber int
|
||||||
|
ETag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newS3Storage(staging *Storage, opts S3Options) (*s3Storage, error) {
|
||||||
|
endpoint, err := url.Parse(opts.Endpoint)
|
||||||
|
if err != nil || endpoint.Host == "" || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
|
||||||
|
return nil, fmt.Errorf("invalid S3 endpoint %q", opts.Endpoint)
|
||||||
|
}
|
||||||
|
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||||
|
transport = transport.Clone()
|
||||||
|
transport.ResponseHeaderTimeout = time.Minute // a stalled endpoint must not hold the store forever
|
||||||
|
pathStyle := !strings.HasSuffix(endpoint.Hostname(), ".amazonaws.com")
|
||||||
|
if opts.PathStyle != nil {
|
||||||
|
pathStyle = *opts.PathStyle
|
||||||
|
}
|
||||||
|
if pathStyle {
|
||||||
|
endpoint.Path = "/" + opts.Bucket
|
||||||
|
} else {
|
||||||
|
endpoint.Host = opts.Bucket + "." + endpoint.Host
|
||||||
|
endpoint.Path = "/"
|
||||||
|
}
|
||||||
|
opts.Region = cmp.Or(opts.Region, "us-east-1")
|
||||||
|
return &s3Storage{Storage: staging, client: &http.Client{Transport: transport}, bucketURL: endpoint, opts: opts}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) blobKey(id uint64) string {
|
||||||
|
return path.Join(s.opts.Prefix, "blobs", strconv.FormatUint(id, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) metadataDir() string {
|
||||||
|
return path.Join(s.opts.Prefix, "metadata") + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) metadataKey(id uint64) string {
|
||||||
|
return s.metadataDir() + strconv.FormatUint(id, 10) + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) url(key string, query url.Values) *url.URL {
|
||||||
|
u := *s.bucketURL
|
||||||
|
u.Path = path.Join(u.Path, key)
|
||||||
|
u.RawPath = strings.NewReplacer("+", "%20", "%2F", "/").Replace(url.QueryEscape(u.Path)) // SigV4 encoding
|
||||||
|
u.RawQuery = strings.ReplaceAll(query.Encode(), "+", "%20")
|
||||||
|
return &u
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign authorizes req with AWS Signature Version 4, leaving the payload unsigned so bodies can stream.
|
||||||
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
|
||||||
|
func (s *s3Storage) sign(req *http.Request, now time.Time) {
|
||||||
|
date := now.UTC().Format("20060102T150405Z")
|
||||||
|
req.Header.Set("X-Amz-Date", date)
|
||||||
|
if req.Header.Get("X-Amz-Content-Sha256") == "" {
|
||||||
|
req.Header.Set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD")
|
||||||
|
}
|
||||||
|
headers := map[string]string{"host": req.URL.Host}
|
||||||
|
for name, values := range req.Header {
|
||||||
|
if name = strings.ToLower(name); strings.HasPrefix(name, "x-amz-") {
|
||||||
|
headers[name] = strings.Join(values, ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := slices.Sorted(maps.Keys(headers))
|
||||||
|
canonical := []string{req.Method, req.URL.EscapedPath(), req.URL.RawQuery}
|
||||||
|
for _, name := range names {
|
||||||
|
canonical = append(canonical, name+":"+headers[name])
|
||||||
|
}
|
||||||
|
canonical = append(canonical, "", strings.Join(names, ";"), req.Header.Get("X-Amz-Content-Sha256"))
|
||||||
|
scope := date[:8] + "/" + s.opts.Region + "/s3/aws4_request"
|
||||||
|
signature := []byte("AWS4" + s.opts.SecretKey)
|
||||||
|
for _, data := range []string{date[:8], s.opts.Region, "s3", "aws4_request", fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%x", date, scope, sha256.Sum256([]byte(strings.Join(canonical, "\n"))))} { // key derivation, the last round signs
|
||||||
|
mac := hmac.New(sha256.New, signature)
|
||||||
|
_, _ = mac.Write([]byte(data))
|
||||||
|
signature = mac.Sum(nil)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s,SignedHeaders=%s,Signature=%x", s.opts.AccessKey, scope, strings.Join(names, ";"), signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// do returns the response to a signed request when it succeeded, fs.ErrNotExist for a missing key.
|
||||||
|
func (s *s3Storage) do(ctx context.Context, method, key string, query url.Values, header http.Header, body io.Reader) (*http.Response, error) {
|
||||||
|
var size int64
|
||||||
|
if sized, ok := body.(interface{ Size() int64 }); ok {
|
||||||
|
size = sized.Size()
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
body = nil
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, s.url(key, query).String(), body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.ContentLength = size
|
||||||
|
maps.Copy(req.Header, header)
|
||||||
|
s.sign(req, time.Now())
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusMultipleChoices {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
defer drain(resp)
|
||||||
|
var failure struct{ Code, Message string }
|
||||||
|
_ = xml.NewDecoder(resp.Body).Decode(&failure)
|
||||||
|
if resp.StatusCode == http.StatusNotFound && (failure.Code == "NoSuchKey" || failure.Code == "" && method == http.MethodHead) {
|
||||||
|
return nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("S3 %s %s: %s", method, key, cmp.Or(strings.TrimSpace(failure.Code+" "+failure.Message), resp.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) call(ctx context.Context, method, key string, query url.Values, body io.Reader, result any) (http.Header, error) {
|
||||||
|
resp, err := s.do(ctx, method, key, query, nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer drain(resp)
|
||||||
|
if result == nil {
|
||||||
|
return resp.Header, nil
|
||||||
|
}
|
||||||
|
return resp.Header, xml.NewDecoder(resp.Body).Decode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain reads a response to its end before closing it, so its connection gets reused.
|
||||||
|
func drain(resp *http.Response) {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) Exist(id uint64) (bool, error) {
|
||||||
|
_, err := s.call(context.Background(), http.MethodHead, s.blobKey(id), nil, nil, nil)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
|
written, err := s.Storage.Commit(id, size)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = s.Storage.Remove(id) }()
|
||||||
|
file, err := os.Open(s.filename(id))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if err := s.upload(context.Background(), s.blobKey(id), file, written); err != nil {
|
||||||
|
return 0, fmt.Errorf("upload cache to S3: %w", err)
|
||||||
|
}
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload always goes in parts, the only way S3 takes an object over 5 GiB.
|
||||||
|
func (s *s3Storage) upload(ctx context.Context, key string, file io.ReaderAt, size int64) error {
|
||||||
|
var created struct {
|
||||||
|
UploadID string `xml:"UploadId"`
|
||||||
|
}
|
||||||
|
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploads": {""}}, nil, &created); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var completion struct {
|
||||||
|
XMLName xml.Name `xml:"CompleteMultipartUpload"`
|
||||||
|
Parts []s3Part `xml:"Part"`
|
||||||
|
}
|
||||||
|
for offset := int64(0); offset == 0 || offset < size; offset += s3PartSize {
|
||||||
|
number, length := len(completion.Parts)+1, min(s3PartSize, size-offset)
|
||||||
|
query := url.Values{"partNumber": {strconv.Itoa(number)}, "uploadId": {created.UploadID}}
|
||||||
|
header, err := s.call(ctx, http.MethodPut, key, query, io.NewSectionReader(file, offset, length), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
completion.Parts = append(completion.Parts, s3Part{number, header.Get("ETag")})
|
||||||
|
}
|
||||||
|
body, err := xml.Marshal(completion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var result struct{ Code, Message string } // a completion can fail after its 200 status
|
||||||
|
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploadId": {created.UploadID}}, bytes.NewReader(body), &result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.Code != "" {
|
||||||
|
return fmt.Errorf("S3 complete upload %s: %s %s", key, result.Code, result.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||||
|
header := http.Header{}
|
||||||
|
if byteRange := r.Header.Get("Range"); byteRange != "" {
|
||||||
|
header.Set("Range", byteRange)
|
||||||
|
}
|
||||||
|
resp, err := s.do(r.Context(), http.MethodGet, s.blobKey(id), nil, header, nil)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
http.Error(w, "read cache from S3", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
for _, name := range []string{"Content-Length", "Content-Range"} {
|
||||||
|
if value := resp.Header.Get(name); value != "" {
|
||||||
|
w.Header().Set(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove unpublishes an entry before deleting its blob, so no runner imports it without its bytes.
|
||||||
|
func (s *s3Storage) Remove(id uint64) error {
|
||||||
|
for _, key := range []string{s.metadataKey(id), s.blobKey(id)} {
|
||||||
|
if _, err := s.call(context.Background(), http.MethodDelete, key, nil, nil, nil); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("remove %s from S3: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.Storage.Remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) publish(cache *Cache) error {
|
||||||
|
data, err := json.Marshal(cache)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.call(context.Background(), http.MethodPut, s.metadataKey(cache.ID), nil, bytes.NewReader(data), nil); err != nil {
|
||||||
|
return fmt.Errorf("publish cache to S3: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) published(ctx context.Context) (map[uint64]bool, error) {
|
||||||
|
ids := make(map[uint64]bool)
|
||||||
|
query := url.Values{"list-type": {"2"}, "prefix": {s.metadataDir()}}
|
||||||
|
for {
|
||||||
|
var page struct {
|
||||||
|
Contents []struct{ Key string }
|
||||||
|
IsTruncated bool
|
||||||
|
NextContinuationToken string
|
||||||
|
}
|
||||||
|
if _, err := s.call(ctx, http.MethodGet, "", query, nil, &page); err != nil {
|
||||||
|
return nil, fmt.Errorf("list S3 cache metadata: %w", err)
|
||||||
|
}
|
||||||
|
for _, object := range page.Contents {
|
||||||
|
if id, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimPrefix(object.Key, s.metadataDir()), ".json"), 10, 64); err == nil {
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !page.IsTruncated {
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
query.Set("continuation-token", page.NextContinuationToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) fetch(ctx context.Context, id uint64) (*Cache, error) {
|
||||||
|
resp, err := s.do(ctx, http.MethodGet, s.metadataKey(id), nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
cache := &Cache{}
|
||||||
|
if err := json.UnmarshalRead(resp.Body, cache); err != nil {
|
||||||
|
return nil, fmt.Errorf("read S3 cache metadata %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/johannesboyne/gofakes3"
|
||||||
|
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandler_S3SharesEntriesAndDropsExpiredOnes(t *testing.T) {
|
||||||
|
backend := s3mem.New()
|
||||||
|
require.NoError(t, backend.CreateBucket("cache"))
|
||||||
|
server := httptest.NewServer(gofakes3.New(backend).Server())
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
opts := Options{S3: &S3Options{Endpoint: server.URL, Bucket: "cache", Prefix: "runners", AccessKey: "key", SecretKey: "secret"}}
|
||||||
|
saver, restorer := startTestHandler(t, opts), startTestHandler(t, opts)
|
||||||
|
entry := map[string]any{"key": "deps", "version": "v1"}
|
||||||
|
|
||||||
|
finalized, _ := saveV2(t, saver, "deps", "v1", []byte("archive"))
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
require.NoError(t, restorer.importMetadata(t.Context()))
|
||||||
|
found := v2Call(t, restorer, testClient, "GetCacheEntryDownloadURL", entry)
|
||||||
|
require.Equal(t, true, found["ok"])
|
||||||
|
assert.Equal(t, []byte("archive"), getURL(t, found["signed_download_url"].(string)))
|
||||||
|
|
||||||
|
require.NoError(t, backend.ForceDeleteBucket("cache"))
|
||||||
|
require.NoError(t, backend.CreateBucket("cache"))
|
||||||
|
require.NoError(t, restorer.importMetadata(t.Context()))
|
||||||
|
assert.Equal(t, true, v2Call(t, restorer, testClient, "CreateCacheEntry", entry)["ok"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3StorageSignsLikeAWS(t *testing.T) {
|
||||||
|
storage := &s3Storage{opts: S3Options{Region: "us-east-1", AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", nil)
|
||||||
|
req.Header.Set("X-Amz-Content-Sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||||
|
storage.sign(req, time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC))
|
||||||
|
assert.Equal(t, "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date,Signature=34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7", req.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -66,6 +68,18 @@ func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error
|
|||||||
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||||
// never does, so no rename can collide with a block not yet moved.
|
// never does, so no rename can collide with a block not yet moved.
|
||||||
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||||
|
wanted, err := json.Marshal(blockIDs) // a block id is client-chosen, so it cannot be a separator
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list := filepath.Join(s.tempDir(id), blockFilePrefix+"list")
|
||||||
|
if recorded, err := os.ReadFile(list); err == nil {
|
||||||
|
// A retry repeats the list it already sent; another one would reorder what is now staged.
|
||||||
|
if !bytes.Equal(recorded, wanted) {
|
||||||
|
return fmt.Errorf("cache %d was already assembled from a different block list", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
for i, blockID := range blockIDs {
|
for i, blockID := range blockIDs {
|
||||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return os.WriteFile(list, wanted, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
@@ -143,9 +157,13 @@ func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
|||||||
http.ServeFile(w, r, name)
|
http.ServeFile(w, r, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Remove(id uint64) {
|
// Remove deletes an entry's blob and any staged parts. It reports failure so the caller can
|
||||||
_ = os.Remove(s.filename(id))
|
// keep the entry and retry, rather than dropping the only reference to bytes on disk.
|
||||||
_ = os.RemoveAll(s.tempDir(id))
|
func (s *Storage) Remove(id uint64) error {
|
||||||
|
if err := os.Remove(s.filename(id)); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.RemoveAll(s.tempDir(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) filename(id uint64) string {
|
func (s *Storage) filename(id uint64) string {
|
||||||
|
|||||||
+38
-109
@@ -6,11 +6,12 @@ package artifacts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -50,65 +51,29 @@ type ResponseMessage struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WritableFile interface {
|
|
||||||
io.WriteCloser
|
|
||||||
}
|
|
||||||
|
|
||||||
type WriteFS interface {
|
|
||||||
OpenWritable(name string) (WritableFile, error)
|
|
||||||
OpenAppendable(name string) (WritableFile, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type readWriteFSImpl struct{}
|
|
||||||
|
|
||||||
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
|
|
||||||
return os.Open(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fwfs readWriteFSImpl) OpenWritable(name string) (WritableFile, error) {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = file.Seek(0, io.SeekEnd)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var gzipExtension = ".gz__"
|
var gzipExtension = ".gz__"
|
||||||
|
|
||||||
func safeResolve(baseDir, relPath string) string {
|
func safeResolve(baseDir, relPath string) string {
|
||||||
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
|
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
func writeJSON(w http.ResponseWriter, value any) {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploads(router *httprouter.Router, baseDir string) {
|
||||||
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
runID := params.ByName("runId")
|
runID := params.ByName("runId")
|
||||||
|
|
||||||
json, err := json.Marshal(FileContainerResourceURL{
|
writeJSON(w, FileContainerResourceURL{
|
||||||
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
|
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = w.Write(json)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
@@ -122,67 +87,47 @@ func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
|||||||
safeRunPath := safeResolve(baseDir, runID)
|
safeRunPath := safeResolve(baseDir, runID)
|
||||||
safePath := safeResolve(safeRunPath, itemPath)
|
safePath := safeResolve(safeRunPath, itemPath)
|
||||||
|
|
||||||
file, err := func() (WritableFile, error) {
|
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
|
||||||
contentRange := req.Header.Get("Content-Range")
|
panic(err)
|
||||||
if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
|
|
||||||
return fsys.OpenAppendable(safePath)
|
|
||||||
}
|
}
|
||||||
return fsys.OpenWritable(safePath)
|
flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
|
||||||
}()
|
appendUpload := req.Header.Get("Content-Range")
|
||||||
|
if appendUpload != "" && !strings.HasPrefix(appendUpload, "bytes 0-") {
|
||||||
|
flags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(safePath, flags, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
writer, ok := file.(io.Writer)
|
|
||||||
if !ok {
|
|
||||||
panic(errors.New("File is not writable"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Body == nil {
|
if req.Body == nil {
|
||||||
panic(errors.New("No body given"))
|
panic(errors.New("no body given"))
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(writer, req.Body)
|
_, err = io.Copy(file, req.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
json, err := json.Marshal(ResponseMessage{
|
writeJSON(w, ResponseMessage{
|
||||||
Message: "success",
|
Message: "success",
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = w.Write(json)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
json, err := json.Marshal(ResponseMessage{
|
writeJSON(w, ResponseMessage{
|
||||||
Message: "success",
|
Message: "success",
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = w.Write(json)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
func downloads(router *httprouter.Router, baseDir string) {
|
||||||
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
runID := params.ByName("runId")
|
runID := params.ByName("runId")
|
||||||
|
|
||||||
safePath := safeResolve(baseDir, runID)
|
safePath := safeResolve(baseDir, runID)
|
||||||
|
|
||||||
entries, err := fs.ReadDir(fsys, safePath)
|
entries, err := os.ReadDir(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -195,18 +140,10 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
json, err := json.Marshal(NamedFileContainerResourceURLResponse{
|
writeJSON(w, NamedFileContainerResourceURLResponse{
|
||||||
Count: len(list),
|
Count: len(list),
|
||||||
Value: list,
|
Value: list,
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = w.Write(json)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
@@ -215,7 +152,7 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
||||||
|
|
||||||
var files []ContainerItem
|
var files []ContainerItem
|
||||||
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error {
|
err := filepath.WalkDir(safePath, func(path string, entry fs.DirEntry, err error) error {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
rel, err := filepath.Rel(safePath, path)
|
rel, err := filepath.Rel(safePath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,17 +178,9 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
json, err := json.Marshal(ContainerItemResponse{
|
writeJSON(w, ContainerItemResponse{
|
||||||
Value: files,
|
Value: files,
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = w.Write(json)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
||||||
@@ -259,15 +188,16 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|||||||
|
|
||||||
safePath := safeResolve(baseDir, path)
|
safePath := safeResolve(baseDir, path)
|
||||||
|
|
||||||
file, err := fsys.Open(safePath)
|
file, err := os.Open(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// try gzip file
|
// try gzip file
|
||||||
file, err = fsys.Open(safePath + gzipExtension)
|
file, err = os.Open(safePath + gzipExtension)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
w.Header().Add("Content-Encoding", "gzip")
|
w.Header().Add("Content-Encoding", "gzip")
|
||||||
}
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
_, err = io.Copy(w, file)
|
_, err = io.Copy(w, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -287,19 +217,18 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
|||||||
router := httprouter.New()
|
router := httprouter.New()
|
||||||
|
|
||||||
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
||||||
fsys := readWriteFSImpl{}
|
uploads(router, artifactPath)
|
||||||
uploads(router, artifactPath, fsys)
|
downloads(router, artifactPath)
|
||||||
downloads(router, artifactPath, fsys)
|
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%s", addr, port),
|
Addr: net.JoinHostPort(addr, port),
|
||||||
ReadHeaderTimeout: 2 * time.Second,
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
}
|
}
|
||||||
|
|
||||||
// run server
|
// run server
|
||||||
go func() {
|
go func() {
|
||||||
logger.Infof("Start server on http://%s:%s", addr, port)
|
logger.Infof("Start server on http://%s", server.Addr)
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Fatal(err)
|
logger.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-314
@@ -7,8 +7,7 @@ package artifacts
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,238 +17,18 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type writableMapFile struct {
|
|
||||||
fstest.MapFile
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *writableMapFile) Write(data []byte) (int, error) {
|
|
||||||
f.Data = data
|
|
||||||
return len(data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *writableMapFile) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type writeMapFS struct {
|
|
||||||
fstest.MapFS
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fsys writeMapFS) OpenWritable(name string) (WritableFile, error) {
|
|
||||||
file := &writableMapFile{
|
|
||||||
MapFile: fstest.MapFile{
|
|
||||||
Data: []byte("content2"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
fsys.MapFS[name] = &file.MapFile
|
|
||||||
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fsys writeMapFS) OpenAppendable(name string) (WritableFile, error) {
|
|
||||||
file := &writableMapFile{
|
|
||||||
MapFile: fstest.MapFile{
|
|
||||||
Data: []byte("content2"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
fsys.MapFS[name] = &file.MapFile
|
|
||||||
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewArtifactUploadPrepare(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
|
||||||
|
|
||||||
router := httprouter.New()
|
|
||||||
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
assert.Fail("Wrong status")
|
|
||||||
}
|
|
||||||
|
|
||||||
response := FileContainerResourceURL{}
|
|
||||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal("http://localhost/upload/1", response.FileContainerResourceURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestArtifactUploadBlob(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
|
||||||
|
|
||||||
router := httprouter.New()
|
|
||||||
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=some/file", strings.NewReader("content"))
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
assert.Fail("Wrong status")
|
|
||||||
}
|
|
||||||
|
|
||||||
response := ResponseMessage{}
|
|
||||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal("success", response.Message)
|
|
||||||
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFinalizeArtifactUpload(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
|
||||||
|
|
||||||
router := httprouter.New()
|
|
||||||
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPatch, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
assert.Fail("Wrong status")
|
|
||||||
}
|
|
||||||
|
|
||||||
response := ResponseMessage{}
|
|
||||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal("success", response.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListArtifacts(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
|
||||||
"artifact/server/path/1/file.txt": {
|
|
||||||
Data: []byte(""),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
router := httprouter.New()
|
|
||||||
downloads(router, "artifact/server/path", memfs)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "http://localhost/_apis/pipelines/workflows/1/artifacts", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
|
||||||
}
|
|
||||||
|
|
||||||
response := NamedFileContainerResourceURLResponse{}
|
|
||||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(1, response.Count)
|
|
||||||
assert.Equal("file.txt", response.Value[0].Name)
|
|
||||||
assert.Equal("http://localhost/download/1", response.Value[0].FileContainerResourceURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListArtifactContainer(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
|
||||||
"artifact/server/path/1/some/file": {
|
|
||||||
Data: []byte(""),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
router := httprouter.New()
|
|
||||||
downloads(router, "artifact/server/path", memfs)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "http://localhost/download/1?itemPath=some/file", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
|
||||||
}
|
|
||||||
|
|
||||||
response := ContainerItemResponse{}
|
|
||||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(response.Value, 1)
|
|
||||||
assert.Equal("some/file", response.Value[0].Path)
|
|
||||||
assert.Equal("file", response.Value[0].ItemType)
|
|
||||||
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDownloadArtifactFile(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
|
||||||
"artifact/server/path/1/some/file": {
|
|
||||||
Data: []byte("content"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
router := httprouter.New()
|
|
||||||
downloads(router, "artifact/server/path", memfs)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/1/some/file", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
|
||||||
}
|
|
||||||
|
|
||||||
data := rr.Body.Bytes()
|
|
||||||
|
|
||||||
assert.Equal("content", string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
|
|
||||||
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
|
|
||||||
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
|
|
||||||
// and reachable everywhere, including when the CI job is itself a container.
|
|
||||||
func TestArtifactFlow(t *testing.T) {
|
func TestArtifactFlow(t *testing.T) {
|
||||||
artifactPath := t.TempDir()
|
artifactPath := t.TempDir()
|
||||||
|
|
||||||
// Serve the exact routes Serve() wires up, on a real loopback socket via httptest. httptest
|
|
||||||
// picks a free port and Close() tears the server down synchronously — avoiding both the
|
|
||||||
// port-rebind race and Serve()'s detached ListenAndServe goroutine, which logger.Fatal()s
|
|
||||||
// (process exit) on a bind error and can outlive the test's temp-dir cleanup.
|
|
||||||
router := httprouter.New()
|
router := httprouter.New()
|
||||||
fsys := readWriteFSImpl{}
|
uploads(router, artifactPath)
|
||||||
uploads(router, artifactPath, fsys)
|
downloads(router, artifactPath)
|
||||||
downloads(router, artifactPath, fsys)
|
|
||||||
server := httptest.NewServer(router)
|
server := httptest.NewServer(router)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -257,8 +36,6 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
client := server.Client()
|
client := server.Client()
|
||||||
client.Timeout = 5 * time.Second
|
client.Timeout = 5 * time.Second
|
||||||
|
|
||||||
// request performs one HTTP call and returns the status and body. The default transport adds
|
|
||||||
// Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain.
|
|
||||||
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
|
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req, err := http.NewRequest(method, rawURL, body)
|
req, err := http.NewRequest(method, rawURL, body)
|
||||||
@@ -289,6 +66,8 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
|
|
||||||
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
require.NoError(t, json.Unmarshal(data, &msg))
|
||||||
|
require.Equal(t, "success", msg.Message)
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
@@ -314,6 +93,21 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
require.Equal(t, content, string(stored))
|
require.Equal(t, content, string(stored))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("content-range", func(t *testing.T) {
|
||||||
|
const rawURL = "/upload/4?itemPath=chunks.txt"
|
||||||
|
status, data := request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("first"),
|
||||||
|
http.Header{"Content-Range": []string{"bytes 0-4/11"}})
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
|
||||||
|
status, data = request(t, http.MethodPut, baseURL+rawURL, strings.NewReader("-second"),
|
||||||
|
http.Header{"Content-Range": []string{"bytes 5-11/11"}})
|
||||||
|
require.Equal(t, http.StatusOK, status, string(data))
|
||||||
|
|
||||||
|
stored, err := os.ReadFile(filepath.Join(artifactPath, "4", "chunks.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "first-second", string(stored))
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("gzip-roundtrip", func(t *testing.T) {
|
t.Run("gzip-roundtrip", func(t *testing.T) {
|
||||||
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
||||||
|
|
||||||
@@ -365,9 +159,7 @@ func TestArtifactFlow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMkdirFsImplSafeResolve(t *testing.T) {
|
func TestSafeResolve(t *testing.T) {
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
baseDir := "/foo/bar"
|
baseDir := "/foo/bar"
|
||||||
|
|
||||||
tests := map[string]struct {
|
tests := map[string]struct {
|
||||||
@@ -385,97 +177,13 @@ func TestMkdirFsImplSafeResolve(t *testing.T) {
|
|||||||
|
|
||||||
for name, tc := range tests {
|
for name, tc := range tests {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
assert.Equal(tc.want, safeResolve(baseDir, tc.input))
|
require.Equal(t, tc.want, safeResolve(baseDir, tc.input))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
|
|
||||||
fsys := readWriteFSImpl{}
|
|
||||||
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
|
|
||||||
|
|
||||||
w, err := fsys.OpenWritable(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = w.Write([]byte("first"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
|
|
||||||
w, err = fsys.OpenAppendable(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = w.Write([]byte("-second"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
|
|
||||||
got, err := os.ReadFile(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "first-second", string(got))
|
|
||||||
|
|
||||||
w, err = fsys.OpenWritable(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = w.Write([]byte("replaced"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, w.Close())
|
|
||||||
|
|
||||||
got, err = os.ReadFile(name)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "replaced", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
|
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
|
||||||
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
|
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
|
||||||
require.NotNil(t, cancel)
|
require.NotNil(t, cancel)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
memfs := fstest.MapFS(map[string]*fstest.MapFile{
|
|
||||||
"artifact/server/path/some/file": {
|
|
||||||
Data: []byte("content"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
router := httprouter.New()
|
|
||||||
downloads(router, "artifact/server/path", memfs)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "http://localhost/artifact/2/../../some/file", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
assert.FailNow(fmt.Sprintf("Wrong status: %d", status))
|
|
||||||
}
|
|
||||||
|
|
||||||
data := rr.Body.Bytes()
|
|
||||||
|
|
||||||
assert.Equal("content", string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestArtifactUploadBlobUnsafePath(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
memfs := fstest.MapFS(map[string]*fstest.MapFile{})
|
|
||||||
|
|
||||||
router := httprouter.New()
|
|
||||||
uploads(router, "artifact/server/path", writeMapFS{memfs})
|
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPut, "http://localhost/upload/1?itemPath=../../some/file", strings.NewReader("content"))
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
router.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if status := rr.Code; status != http.StatusOK {
|
|
||||||
assert.Fail("Wrong status")
|
|
||||||
}
|
|
||||||
|
|
||||||
response := ResponseMessage{}
|
|
||||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal("success", response.Message)
|
|
||||||
assert.Equal("content", string(memfs["artifact/server/path/1/some/file"].Data))
|
|
||||||
}
|
|
||||||
@@ -1,60 +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 "slices"
|
|
||||||
|
|
||||||
// CartesianProduct takes map of lists and returns list of unique tuples
|
|
||||||
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
|
|
||||||
listNames := make([]string, 0)
|
|
||||||
lists := make([][]any, 0)
|
|
||||||
for k, v := range mapOfLists {
|
|
||||||
listNames = append(listNames, k)
|
|
||||||
lists = append(lists, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
listCart := cartN(lists...)
|
|
||||||
|
|
||||||
rtn := make([]map[string]any, 0)
|
|
||||||
for _, list := range listCart {
|
|
||||||
vMap := make(map[string]any)
|
|
||||||
for i, v := range list {
|
|
||||||
vMap[listNames[i]] = v
|
|
||||||
}
|
|
||||||
rtn = append(rtn, vMap)
|
|
||||||
}
|
|
||||||
return rtn
|
|
||||||
}
|
|
||||||
|
|
||||||
func cartN(a ...[]any) [][]any {
|
|
||||||
c := 1
|
|
||||||
for _, a := range a {
|
|
||||||
c *= len(a)
|
|
||||||
}
|
|
||||||
if c == 0 || len(a) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
p := make([][]any, c)
|
|
||||||
b := make([]any, c*len(a))
|
|
||||||
n := make([]int, len(a))
|
|
||||||
s := 0
|
|
||||||
for i := range p {
|
|
||||||
e := s + len(a)
|
|
||||||
pi := b[s:e]
|
|
||||||
p[i] = pi
|
|
||||||
s = e
|
|
||||||
for j, n := range n {
|
|
||||||
pi[j] = a[j][n]
|
|
||||||
}
|
|
||||||
for j := range slices.Backward(n) {
|
|
||||||
n[j]++
|
|
||||||
if n[j] < len(a[j]) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
n[j] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCartesianProduct(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
input := map[string][]any{
|
|
||||||
"foo": {1, 2, 3, 4},
|
|
||||||
"bar": {"a", "b", "c"},
|
|
||||||
"baz": {false, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
output := CartesianProduct(input)
|
|
||||||
assert.Len(output, 24)
|
|
||||||
|
|
||||||
for _, v := range output {
|
|
||||||
assert.Len(v, 3)
|
|
||||||
|
|
||||||
assert.Contains(v, "foo")
|
|
||||||
assert.Contains(v, "bar")
|
|
||||||
assert.Contains(v, "baz")
|
|
||||||
}
|
|
||||||
|
|
||||||
input = map[string][]any{
|
|
||||||
"foo": {1, 2, 3, 4},
|
|
||||||
"bar": {},
|
|
||||||
"baz": {false, true},
|
|
||||||
}
|
|
||||||
output = CartesianProduct(input)
|
|
||||||
assert.Empty(output)
|
|
||||||
|
|
||||||
input = map[string][]any{}
|
|
||||||
output = CartesianProduct(input)
|
|
||||||
assert.Empty(output)
|
|
||||||
}
|
|
||||||
+1
-24
@@ -54,22 +54,6 @@ func NewPipelineExecutor(executors ...Executor) Executor {
|
|||||||
return rtn
|
return rtn
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConditionalExecutor creates a new executor based on conditions
|
|
||||||
func NewConditionalExecutor(conditional Conditional, trueExecutor, falseExecutor Executor) Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
if conditional(ctx) {
|
|
||||||
if trueExecutor != nil {
|
|
||||||
return trueExecutor(ctx)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if falseExecutor != nil {
|
|
||||||
return falseExecutor(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewErrorExecutor creates a new executor that always errors out
|
// NewErrorExecutor creates a new executor that always errors out
|
||||||
func NewErrorExecutor(err error) Executor {
|
func NewErrorExecutor(err error) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -187,15 +171,8 @@ func (e Executor) Finally(finally Executor) Executor {
|
|||||||
err := e(ctx)
|
err := e(ctx)
|
||||||
err2 := finally(ctx)
|
err2 := finally(ctx)
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
return fmt.Errorf("Error occurred running finally: %v (original error: %v)", err2, err)
|
return fmt.Errorf("error occurred running finally: %v (original error: %v)", err2, err)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not return an inverted conditional
|
|
||||||
func (c Conditional) Not() Conditional {
|
|
||||||
return func(ctx context.Context) bool {
|
|
||||||
return !c(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,43 +45,6 @@ func TestNewWorkflow(t *testing.T) {
|
|||||||
assert.Equal(2, runcount)
|
assert.Equal(2, runcount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewConditionalExecutor(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
trueCount := 0
|
|
||||||
falseCount := 0
|
|
||||||
|
|
||||||
err := NewConditionalExecutor(func(ctx context.Context) bool {
|
|
||||||
return false
|
|
||||||
}, func(ctx context.Context) error {
|
|
||||||
trueCount++
|
|
||||||
return nil
|
|
||||||
}, func(ctx context.Context) error {
|
|
||||||
falseCount++
|
|
||||||
return nil
|
|
||||||
})(ctx)
|
|
||||||
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(0, trueCount)
|
|
||||||
assert.Equal(1, falseCount)
|
|
||||||
|
|
||||||
err = NewConditionalExecutor(func(ctx context.Context) bool {
|
|
||||||
return true
|
|
||||||
}, func(ctx context.Context) error {
|
|
||||||
trueCount++
|
|
||||||
return nil
|
|
||||||
}, func(ctx context.Context) error {
|
|
||||||
falseCount++
|
|
||||||
return nil
|
|
||||||
})(ctx)
|
|
||||||
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(1, trueCount)
|
|
||||||
assert.Equal(1, falseCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
|
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
|
||||||
// block until wantActive are in flight so the peak is exact without sleeping, and later copies
|
// block until wantActive are in flight so the peak is exact without sleeping, and later copies
|
||||||
// find the gate already open so the last one still finishes with no partner left.
|
// find the gate already open so the last one still finishes with no partner left.
|
||||||
@@ -223,10 +186,3 @@ func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
|||||||
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConditionalNot(t *testing.T) {
|
|
||||||
cond := Conditional(func(context.Context) bool { return false })
|
|
||||||
if !cond.Not()(context.Background()) {
|
|
||||||
t.Fatal("inverted conditional should be true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+113
-113
@@ -36,7 +36,6 @@ var (
|
|||||||
cloneLocks lock.Keyed[string] // key: clone target directory
|
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||||
|
|
||||||
ErrShortRef = errors.New("short SHA references are not supported")
|
ErrShortRef = errors.New("short SHA references are not supported")
|
||||||
ErrNoRepo = errors.New("unable to find git repo")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
||||||
@@ -187,19 +186,16 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindGithubRepo get the repo
|
// FindGithubRepo get the repo
|
||||||
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, error) {
|
||||||
goGitMu.Lock()
|
goGitMu.Lock()
|
||||||
defer goGitMu.Unlock()
|
defer goGitMu.Unlock()
|
||||||
if remoteName == "" {
|
|
||||||
remoteName = "origin"
|
|
||||||
}
|
|
||||||
|
|
||||||
url, err := findGitRemoteURL(ctx, file, remoteName)
|
url, err := findGitRemoteURL(ctx, file, "origin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_, slug, err := findGitSlug(url, githubInstance)
|
_, slug := findGitSlug(url, githubInstance)
|
||||||
return slug, err
|
return slug, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
||||||
@@ -226,25 +222,25 @@ func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error
|
|||||||
return remote.Config().URLs[0], nil
|
return remote.Config().URLs[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGitSlug(url, githubInstance string) (string, string, error) { //nolint:unparam // pre-existing issue from nektos/act
|
func findGitSlug(url, githubInstance string) (string, string) {
|
||||||
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "CodeCommit", matches[2], nil
|
return "CodeCommit", matches[2]
|
||||||
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "CodeCommit", matches[2], nil
|
return "CodeCommit", matches[2]
|
||||||
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
||||||
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
||||||
} else if githubInstance != "github.com" {
|
} else if githubInstance != "github.com" {
|
||||||
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
||||||
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
|
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
|
||||||
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
|
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
||||||
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||||
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil
|
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", url, nil
|
return "", url
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
||||||
@@ -278,11 +274,12 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
|||||||
return r, true, nil
|
return r, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
switch {
|
||||||
|
case err != nil:
|
||||||
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||||
} else if len(remote.Config().URLs) == 0 {
|
case len(remote.Config().URLs) == 0:
|
||||||
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
||||||
} else {
|
default:
|
||||||
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(input.Dir); err != nil {
|
if err := os.RemoveAll(input.Dir); err != nil {
|
||||||
@@ -328,23 +325,6 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
|||||||
return r, false, nil
|
return r, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
|
||||||
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
|
|
||||||
fetchOptions.Force = true
|
|
||||||
pullOptions.Force = true
|
|
||||||
|
|
||||||
if token != "" {
|
|
||||||
auth := &http.BasicAuth{
|
|
||||||
Username: "token",
|
|
||||||
Password: token,
|
|
||||||
}
|
|
||||||
fetchOptions.Auth = auth
|
|
||||||
pullOptions.Auth = auth
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchOptions, pullOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGitCloneExecutor creates an executor to clone git repos
|
// NewGitCloneExecutor creates an executor to clone git repos
|
||||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -366,26 +346,35 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
|
|
||||||
isOfflineMode := input.OfflineMode
|
isOfflineMode := input.OfflineMode
|
||||||
|
|
||||||
// fetch latest changes
|
fetchOptions := git.FetchOptions{
|
||||||
fetchOptions, pullOptions := gitOptions(input.Token)
|
RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD", "refs/heads/*:refs/remotes/origin/*"},
|
||||||
|
Force: true,
|
||||||
if input.InsecureSkipTLS { // For Gitea
|
InsecureSkipTLS: input.InsecureSkipTLS,
|
||||||
fetchOptions.InsecureSkipTLS = true
|
}
|
||||||
pullOptions.InsecureSkipTLS = true
|
if input.Token != "" {
|
||||||
|
fetchOptions.Auth = &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: input.Token,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||||
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||||
shallow := isShallow(r)
|
if isShallow(r) {
|
||||||
if shallow {
|
|
||||||
fetchOptions.Depth = 1
|
fetchOptions.Depth = 1
|
||||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isOfflineMode {
|
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
||||||
err = r.Fetch(&fetchOptions)
|
// TODO: revalidate a mutable ref with a conditional archive request instead, once every
|
||||||
|
// supported Gitea sends an ETag for them: https://github.com/go-gitea/gitea/pull/39289
|
||||||
|
_, present := refRevision(r, input.Ref)
|
||||||
|
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
|
||||||
|
|
||||||
|
if refresh {
|
||||||
|
err = r.FetchContext(ctx, &fetchOptions)
|
||||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -404,23 +393,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point we need to know if it's a tag or a branch
|
rev, _ = refRevision(r, input.Ref)
|
||||||
// And the easiest way to do it is duck typing
|
|
||||||
//
|
|
||||||
// If err is nil, it's a tag so let's proceed with that hash like we would if
|
|
||||||
// it was a sha
|
|
||||||
refType := "tag"
|
|
||||||
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
|
|
||||||
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
|
|
||||||
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
|
||||||
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
|
|
||||||
refType = "sha"
|
|
||||||
rev = plumbing.Revision(input.Ref)
|
|
||||||
} else {
|
|
||||||
refType = "branch"
|
|
||||||
rev = plumbing.Revision(rName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
@@ -432,44 +405,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the hash resolved doesn't match the ref provided in a workflow then we're
|
|
||||||
// using a branch or tag ref, not a sha
|
|
||||||
//
|
|
||||||
// Repos on disk point to commit hashes, and need to checkout input.Ref before
|
|
||||||
// we try and pull down any changes
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
|
||||||
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
|
|
||||||
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
|
||||||
if err = w.Checkout(&git.CheckoutOptions{
|
|
||||||
Branch: sourceRef,
|
|
||||||
Force: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reusedMsg := ""
|
reusedMsg := ""
|
||||||
|
if isOfflineMode && reused {
|
||||||
switch {
|
|
||||||
case !isOfflineMode && !shallow:
|
|
||||||
// In shallow mode the depth-limited fetch above already advanced the ref.
|
|
||||||
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
|
||||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
|
||||||
}
|
|
||||||
case isOfflineMode && reused:
|
|
||||||
reusedMsg = " (reused in offline mode)"
|
reusedMsg = " (reused in offline mode)"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
||||||
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
|
||||||
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err = w.Checkout(&git.CheckoutOptions{
|
if err = w.Checkout(&git.CheckoutOptions{
|
||||||
Hash: *hash,
|
Hash: *hash,
|
||||||
Force: true,
|
Force: true,
|
||||||
@@ -478,27 +420,29 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = w.Reset(&git.ResetOptions{
|
|
||||||
Mode: git.HardReset,
|
|
||||||
Commit: *hash,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debugf("Checked out %s", input.Ref)
|
logger.Debugf("Checked out %s", input.Ref)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloneAtDepth clones input.URL into input.Dir using opts.
|
// cloneAtDepth falls back to a full clone when the shallow attempt fails.
|
||||||
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
|
||||||
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||||
if input.Depth > 0 {
|
if input.Depth > 0 {
|
||||||
for _, refName := range []plumbing.ReferenceName{
|
if plumbing.IsHash(input.Ref) {
|
||||||
plumbing.NewBranchReferenceName(input.Ref),
|
r, err := fetchPinnedSHA(ctx, input, opts)
|
||||||
plumbing.NewTagReferenceName(input.Ref),
|
if err == nil {
|
||||||
} {
|
return r, nil
|
||||||
|
}
|
||||||
|
logger.Debugf("Shallow fetch of %s at %s failed: %v", input.URL, input.Ref, err)
|
||||||
|
if err := removePartialClone(input.Dir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
refNames := []plumbing.ReferenceName{plumbing.NewBranchReferenceName(input.Ref), plumbing.NewTagReferenceName(input.Ref)}
|
||||||
|
if input.Ref == plumbing.HEAD.String() || strings.HasPrefix(input.Ref, "refs/") {
|
||||||
|
refNames = []plumbing.ReferenceName{plumbing.ReferenceName(input.Ref)} // HEAD is the remote's default branch
|
||||||
|
}
|
||||||
|
for _, refName := range refNames {
|
||||||
shallowOpts := opts
|
shallowOpts := opts
|
||||||
shallowOpts.Depth = input.Depth
|
shallowOpts.Depth = input.Depth
|
||||||
shallowOpts.SingleBranch = true
|
shallowOpts.SingleBranch = true
|
||||||
@@ -510,8 +454,9 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
|
|||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||||
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
if err := removePartialClone(input.Dir); err != nil {
|
||||||
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||||
@@ -520,14 +465,65 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
|
|||||||
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removePartialClone(dir string) error {
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return fmt.Errorf("remove partial clone %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPinnedSHA(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions) (*git.Repository, error) {
|
||||||
|
r, err := git.PlainInit(input.Dir, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := r.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{input.URL}}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.FetchContext(ctx, &git.FetchOptions{
|
||||||
|
RefSpecs: []config.RefSpec{pinnedRefSpec(input.Ref)},
|
||||||
|
Depth: input.Depth,
|
||||||
|
Tags: git.NoTags,
|
||||||
|
Auth: opts.Auth,
|
||||||
|
Progress: opts.Progress,
|
||||||
|
InsecureSkipTLS: opts.InsecureSkipTLS,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinnedRefSpec keeps a hash-shaped name out of refs/heads and refs/tags.
|
||||||
|
func pinnedRefSpec(sha string) config.RefSpec {
|
||||||
|
return config.RefSpec(fmt.Sprintf("+%s:refs/pinned/%s", sha, sha))
|
||||||
|
}
|
||||||
|
|
||||||
|
// refRevision picks the revision to check out and reports whether it resolves locally.
|
||||||
|
func refRevision(r *git.Repository, ref string) (plumbing.Revision, bool) {
|
||||||
|
if plumbing.IsHash(ref) {
|
||||||
|
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
|
||||||
|
_, err := r.CommitObject(plumbing.NewHash(ref))
|
||||||
|
return plumbing.Revision(ref), err == nil
|
||||||
|
}
|
||||||
|
if _, err := r.Tag(ref); err == nil {
|
||||||
|
return plumbing.Revision(path.Join("refs", "tags", ref)), true
|
||||||
|
}
|
||||||
|
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
|
||||||
|
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||||
|
return plumbing.Revision(remoteRef), true
|
||||||
|
}
|
||||||
|
rev := plumbing.Revision(ref)
|
||||||
|
_, err := r.ResolveRevision(rev)
|
||||||
|
return rev, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||||
func isShallow(r *git.Repository) bool {
|
func isShallow(r *git.Repository) bool {
|
||||||
shallows, err := r.Storer.Shallow()
|
shallows, err := r.Storer.Shallow()
|
||||||
return err == nil && len(shallows) > 0
|
return err == nil && len(shallows) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
// shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
|
||||||
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
|
||||||
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||||
tagRef := plumbing.NewTagReferenceName(ref)
|
tagRef := plumbing.NewTagReferenceName(ref)
|
||||||
if _, err := r.Reference(tagRef, false); err == nil {
|
if _, err := r.Reference(tagRef, false); err == nil {
|
||||||
@@ -538,5 +534,9 @@ func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
|||||||
branchRef := plumbing.NewBranchReferenceName(ref)
|
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||||
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||||
}
|
}
|
||||||
|
if plumbing.IsHash(ref) {
|
||||||
|
// The broad refspec carries only advertised tips, never a pinned commit.
|
||||||
|
return pinnedRefSpec(ref), true
|
||||||
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
+188
-42
@@ -7,17 +7,26 @@ package git
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
gogit "github.com/go-git/go-git/v5"
|
||||||
|
gogitconfig "github.com/go-git/go-git/v5/config"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
|
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||||
|
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -45,9 +54,7 @@ func TestFindGitSlug(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range slugTests {
|
for _, tt := range slugTests {
|
||||||
provider, slug, err := findGitSlug(tt.url, "github.com")
|
provider, slug := findGitSlug(tt.url, "github.com")
|
||||||
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(tt.provider, provider)
|
assert.Equal(tt.provider, provider)
|
||||||
assert.Equal(tt.slug, slug)
|
assert.Equal(tt.slug, slug)
|
||||||
}
|
}
|
||||||
@@ -81,45 +88,20 @@ func cleanGitHooks(dir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindGitRemoteURL(t *testing.T) {
|
func TestFindGithubRepoUsesOrigin(t *testing.T) {
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
basedir := t.TempDir()
|
|
||||||
err := gitCmd("init", basedir)
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
err = cleanGitHooks(basedir)
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
remoteURL := "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name"
|
|
||||||
err = gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
u, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(remoteURL, u)
|
|
||||||
|
|
||||||
remoteURL = "[email protected]/AwesomeOwner/MyAwesomeRepo.git"
|
|
||||||
err = gitCmd("-C", basedir, "remote", "add", "upstream", remoteURL)
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
u, err = findGitRemoteURL(context.Background(), basedir, "upstream")
|
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(remoteURL, u)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindGithubRepoUsesOriginAndCustomRemote(t *testing.T) {
|
|
||||||
basedir := t.TempDir()
|
basedir := t.TempDir()
|
||||||
|
const remoteURL = "https://github.com/owner/repo.git"
|
||||||
require.NoError(t, gitCmd("init", basedir))
|
require.NoError(t, gitCmd("init", basedir))
|
||||||
require.NoError(t, cleanGitHooks(basedir))
|
require.NoError(t, cleanGitHooks(basedir))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", "https://github.com/owner/repo.git"))
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "ghe", "[email protected]:team/project.git"))
|
|
||||||
|
|
||||||
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
|
url, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, remoteURL, url)
|
||||||
|
|
||||||
|
slug, err := FindGithubRepo(context.Background(), basedir, "github.com")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "owner/repo", slug)
|
require.Equal(t, "owner/repo", slug)
|
||||||
|
|
||||||
slug, err = FindGithubRepo(context.Background(), basedir, "git.example.com", "ghe")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "team/project", slug)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitFindRef(t *testing.T) {
|
func TestGitFindRef(t *testing.T) {
|
||||||
@@ -456,7 +438,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
||||||
}
|
}
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||||
|
|
||||||
@@ -482,14 +464,43 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
|
||||||
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||||
|
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("commit hash falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||||
})(t.Context()))
|
})(t.Context()))
|
||||||
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
assert.NoFileExists(t, shallowMarker(dir))
|
||||||
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
})
|
||||||
|
|
||||||
|
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
|
||||||
|
remote := unadvertisedRemote(t, true)
|
||||||
|
dir := t.TempDir()
|
||||||
|
cloneAt := func(ref string) error {
|
||||||
|
return NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remote, Ref: ref, Dir: dir, Depth: 1,
|
||||||
|
})(t.Context())
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, cloneAt(unadvertisedSHA))
|
||||||
|
assert.FileExists(t, shallowMarker(dir))
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
olderSHA := gitRevParse(t, workDir, "HEAD~2")
|
||||||
|
require.NoError(t, cloneAt(olderSHA))
|
||||||
|
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
require.NoError(t, os.RemoveAll(remote))
|
||||||
|
require.NoError(t, cloneAt(olderSHA))
|
||||||
|
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||||
@@ -512,6 +523,98 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorTransportSessions(t *testing.T) {
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--initial-branch=main", workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "-a", "v2", "-m", "v2"))
|
||||||
|
|
||||||
|
for name, tt := range map[string]struct {
|
||||||
|
Ref string
|
||||||
|
Depth int
|
||||||
|
}{
|
||||||
|
"shallow branch": {"main", 1},
|
||||||
|
"full clone branch": {"main", 0},
|
||||||
|
"full lightweight tag": {"v1", 0},
|
||||||
|
"full annotated tag": {"v2", 0},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
counter := installCountingTransport(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: workDir, Ref: tt.Ref, Dir: dir, Depth: tt.Depth,
|
||||||
|
})
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Swap(0), "cold clone")
|
||||||
|
assert.Equal(t, gitRevParse(t, workDir, tt.Ref+"^{commit}"), gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Swap(0), "unchanged warm cache")
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.yml"), []byte(name), 0o644))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "add", "action.yml"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", name))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "v1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "-a", "v2", "-m", "v2"))
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("staged"), 0o644))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "add", "action.yml"))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("unstaged"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Load(), "updated warm cache")
|
||||||
|
assert.Equal(t, gitRevParse(t, workDir, "HEAD"), gitRevParse(t, dir, "HEAD"))
|
||||||
|
status, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, string(status))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "pinned"))
|
||||||
|
pinned := gitRevParse(t, workDir, "HEAD")
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
|
||||||
|
|
||||||
|
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.Equal(t, pinned, gitRevParse(t, dir, "HEAD"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingTransport struct {
|
||||||
|
transport.Transport
|
||||||
|
sessions atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
|
||||||
|
c.sessions.Add(1)
|
||||||
|
return c.Transport.NewUploadPackSession(ep, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installCountingTransport(t *testing.T) *countingTransport {
|
||||||
|
t.Helper()
|
||||||
|
counter := &countingTransport{Transport: gogitfile.DefaultClient}
|
||||||
|
gogitclient.InstallProtocol("file", counter)
|
||||||
|
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
|
||||||
|
|
||||||
|
return counter
|
||||||
|
}
|
||||||
|
|
||||||
func gitRevParse(t *testing.T, dir, rev string) string {
|
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||||
@@ -610,3 +713,46 @@ func TestAcquireCloneLock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An unresponsive remote must not pin a job: the refresh has to be interruptible.
|
||||||
|
func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||||
|
block := make(chan struct{})
|
||||||
|
reached := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
once.Do(func() { close(reached) })
|
||||||
|
<-block
|
||||||
|
}))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(block)
|
||||||
|
server.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
dir := filepath.Join(t.TempDir(), "cached-action")
|
||||||
|
repo, err := gogit.PlainInit(dir, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = repo.CreateRemote(&gogitconfig.RemoteConfig{Name: "origin", URLs: []string{server.URL}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir})(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-reached:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("the executor never reached the remote")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.Error(t, err)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("fetch ignored context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,14 +46,14 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
|
|||||||
line, err := pBuf.ReadString('\n')
|
line, err := pBuf.ReadString('\n')
|
||||||
w, _ := lw.buffer.WriteString(line)
|
w, _ := lw.buffer.WriteString(line)
|
||||||
written += w
|
written += w
|
||||||
if err == nil {
|
if err != nil {
|
||||||
lw.handleLine(lw.buffer.String())
|
if err == io.EOF {
|
||||||
lw.buffer.Reset()
|
|
||||||
} else if err == io.EOF {
|
|
||||||
break
|
break
|
||||||
} else {
|
}
|
||||||
return written, err
|
return written, err
|
||||||
}
|
}
|
||||||
|
lw.handleLine(lw.buffer.String())
|
||||||
|
lw.buffer.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
return written, nil
|
return written, nil
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExitCodeError reports a non-zero process exit code from a container command.
|
// ExitCodeError reports a non-zero process exit code from a container command.
|
||||||
@@ -39,7 +45,8 @@ type NewContainerInput struct {
|
|||||||
Privileged bool
|
Privileged bool
|
||||||
UsernsMode string
|
UsernsMode string
|
||||||
Platform string
|
Platform string
|
||||||
Options string
|
RunnerOptions string // container options the runner was configured with, trusted
|
||||||
|
WorkflowOptions string // container options the workflow asked for, untrusted
|
||||||
NetworkAliases []string
|
NetworkAliases []string
|
||||||
ExposedPorts nat.PortSet
|
ExposedPorts nat.PortSet
|
||||||
PortBindings nat.PortMap
|
PortBindings nat.PortMap
|
||||||
@@ -57,14 +64,69 @@ type FileEntry struct {
|
|||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Container and healthcheck states, as plain strings so a caller of Info needs no docker
|
||||||
|
// SDK of its own.
|
||||||
|
const (
|
||||||
|
StateRunning = string(container.StateRunning)
|
||||||
|
|
||||||
|
HealthNone = string(container.NoHealthcheck)
|
||||||
|
HealthStarting = string(container.Starting)
|
||||||
|
HealthHealthy = string(container.Healthy)
|
||||||
|
HealthUnhealthy = string(container.Unhealthy)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrContainerNotFound reports a container the daemon no longer knows. Its text is a
|
||||||
|
// fragment, missingContainerError composes it into the message every operation shares.
|
||||||
|
var ErrContainerNotFound = errors.New("does not exist")
|
||||||
|
|
||||||
|
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
|
||||||
|
type DockerProxy struct {
|
||||||
|
Socket string
|
||||||
|
close func(context.Context) error
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
|
mounts atomic.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DockerProxy) SetMounts(mounts map[string]string) {
|
||||||
|
p.mounts.Store(mounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DockerProxy) Close(ctx context.Context) error {
|
||||||
|
p.closeOnce.Do(func() { p.closeErr = p.close(ctx) })
|
||||||
|
return p.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info is a snapshot of a container, as of one inspect.
|
||||||
|
type Info struct {
|
||||||
|
ID string
|
||||||
|
State string // the docker container state: "created", "running", "exited", ...
|
||||||
|
ExitCode int
|
||||||
|
Health string // one of the Health* constants
|
||||||
|
// HealthOutput is the last healthcheck probe's output.
|
||||||
|
HealthOutput string
|
||||||
|
Ports map[string]string // container port ("5432") to the host port it is published on
|
||||||
|
Mounts map[string]string // container path to its source on the daemon
|
||||||
|
}
|
||||||
|
|
||||||
|
// DaemonPath maps target to its daemon path through the nearest mount with a source, empty without one.
|
||||||
|
func (info *Info) DaemonPath(target string) string {
|
||||||
|
for dir := target; dir != "/" && dir != "."; dir = path.Dir(dir) {
|
||||||
|
if source := info.Mounts[dir]; source != "" {
|
||||||
|
return path.Join(source, strings.TrimPrefix(target, dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Container for managing docker run containers
|
// Container for managing docker run containers
|
||||||
type Container interface {
|
type Container interface {
|
||||||
Create(capAdd, capDrop []string) common.Executor
|
Create(capAdd, capDrop []string) common.Executor
|
||||||
ConnectToNetwork(name string) common.Executor
|
|
||||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||||
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor
|
||||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
|
||||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||||
|
Inspect(ctx context.Context) (*Info, error)
|
||||||
|
DumpLogs(ctx context.Context) error
|
||||||
Pull(forcePull bool) common.Executor
|
Pull(forcePull bool) common.Executor
|
||||||
Start(attach bool) common.Executor
|
Start(attach bool) common.Executor
|
||||||
Exec(command []string, env map[string]string, user, workdir string) common.Executor
|
Exec(command []string, env map[string]string, user, workdir string) common.Executor
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts.go with:
|
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
||||||
|
// * jsontext in place of json.Compact for the seccomp profile
|
||||||
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
||||||
//
|
//
|
||||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||||
@@ -17,8 +18,7 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"encoding/json/jsontext"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -153,6 +153,7 @@ type containerOptions struct {
|
|||||||
runtime string
|
runtime string
|
||||||
autoRemove bool
|
autoRemove bool
|
||||||
init bool
|
init bool
|
||||||
|
umask opts.UmaskOpt
|
||||||
annotations *opts.MapOpts
|
annotations *opts.MapOpts
|
||||||
|
|
||||||
Image string
|
Image string
|
||||||
@@ -327,6 +328,9 @@ func addFlags(flags *pflag.FlagSet) *containerOptions {
|
|||||||
flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm")
|
flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm")
|
||||||
flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use")
|
flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use")
|
||||||
flags.StringVar(&copts.runtime, "runtime", "", "Runtime to use for this container")
|
flags.StringVar(&copts.runtime, "runtime", "", "Runtime to use for this container")
|
||||||
|
flags.Var(&copts.umask, "umask", "Set umask for the container")
|
||||||
|
flags.SetAnnotation("umask", "version", []string{"1.56"})
|
||||||
|
flags.SetAnnotation("umask", "ostype", []string{"linux"})
|
||||||
|
|
||||||
flags.BoolVar(&copts.init, "init", false, "Run an init inside the container that forwards signals and reaps processes")
|
flags.BoolVar(&copts.init, "init", false, "Run an init inside the container that forwards signals and reaps processes")
|
||||||
flags.SetAnnotation("init", "version", []string{"1.25"})
|
flags.SetAnnotation("init", "version", []string{"1.25"})
|
||||||
@@ -351,7 +355,7 @@ type containerConfig struct {
|
|||||||
// parse parses the args for the specified command and generates a Config,
|
// parse parses the args for the specified command and generates a Config,
|
||||||
// a HostConfig and returns them with the specified command.
|
// a HostConfig and returns them with the specified command.
|
||||||
// If the specified args are not valid, it will return an error.
|
// If the specified args are not valid, it will return an error.
|
||||||
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { //nolint:gocyclo // verbatim copy from docker/cli
|
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { //nolint:gocyclo,unparam // verbatim copy from docker/cli
|
||||||
var (
|
var (
|
||||||
attachStdin = copts.attach.Get("stdin")
|
attachStdin = copts.attach.Get("stdin")
|
||||||
attachStdout = copts.attach.Get("stdout")
|
attachStdout = copts.attach.Get("stdout")
|
||||||
@@ -722,6 +726,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
MaskedPaths: maskedPaths,
|
MaskedPaths: maskedPaths,
|
||||||
ReadonlyPaths: readonlyPaths,
|
ReadonlyPaths: readonlyPaths,
|
||||||
Annotations: copts.annotations.GetAll(),
|
Annotations: copts.annotations.GetAll(),
|
||||||
|
Umask: copts.umask.Value(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() {
|
if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() {
|
||||||
@@ -959,11 +964,11 @@ func parseSecurityOpts(securityOpts []string) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return securityOpts, fmt.Errorf("opening seccomp profile (%s) failed: %w", v, err)
|
return securityOpts, fmt.Errorf("opening seccomp profile (%s) failed: %w", v, err)
|
||||||
}
|
}
|
||||||
var b bytes.Buffer
|
profile := jsontext.Value(f)
|
||||||
if err := json.Compact(&b, f); err != nil {
|
if err := profile.Compact(); err != nil {
|
||||||
return securityOpts, fmt.Errorf("compacting json for seccomp profile (%s) failed: %w", v, err)
|
return securityOpts, fmt.Errorf("compacting json for seccomp profile (%s) failed: %w", v, err)
|
||||||
}
|
}
|
||||||
securityOpts[key] = "seccomp=" + b.String()
|
securityOpts[key] = "seccomp=" + string(profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts_test.go with:
|
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts_test.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter
|
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter
|
||||||
//
|
//
|
||||||
@@ -167,6 +167,19 @@ func TestParseRunLinks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRunWithoutUmask(t *testing.T) {
|
||||||
|
_, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"})
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Assert(t, hostConfig.Umask == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunUmask(t *testing.T) {
|
||||||
|
_, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"})
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Assert(t, hostConfig.Umask != nil)
|
||||||
|
assert.Equal(t, uint32(0o22), *hostConfig.Umask)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRunAttach(t *testing.T) {
|
func TestParseRunAttach(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/docker/cli/opts"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
@@ -51,15 +53,16 @@ func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *
|
|||||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||||
flags.SetOutput(io.Discard)
|
flags.SetOutput(io.Discard)
|
||||||
copts := addFlags(flags)
|
copts := addFlags(flags)
|
||||||
|
copts.env = opts.NewListOpts(validateEnv) // addFlags registered this field's address, so the swap takes effect
|
||||||
cf := registerCreateFlags(flags)
|
cf := registerCreateFlags(flags)
|
||||||
|
|
||||||
args, err := shellquote.Split(options)
|
args, err := shellquote.Split(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
|
return flags, copts, cf, fmt.Errorf("cannot split container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := flags.Parse(args); err != nil {
|
if err := flags.Parse(args); err != nil {
|
||||||
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
|
return flags, copts, cf, fmt.Errorf("cannot parse container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return flags, copts, cf, nil
|
return flags, copts, cf, nil
|
||||||
@@ -73,6 +76,30 @@ func createFlagsFromOptions(options string) *createFlags {
|
|||||||
return cf
|
return cf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateEnv is opts.ValidateEnv without its lookup of a bare name in the runner's environment.
|
||||||
|
func validateEnv(val string) (string, error) {
|
||||||
|
if name, _, _ := strings.Cut(val, "="); name == "" {
|
||||||
|
return "", errors.New("invalid environment variable: " + val)
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectHostReadingOptions refuses the flags naming files that are read here, on the
|
||||||
|
// runner, rather than in the container.
|
||||||
|
func rejectHostReadingOptions(options string) error {
|
||||||
|
flags, _, _, err := parseContainerOptions(options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"env-file", "label-file"} {
|
||||||
|
if flags.Changed(name) {
|
||||||
|
return fmt.Errorf("container option --%s reads files from the runner and is not allowed in a workflow", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (cf *createFlags) validate() error {
|
func (cf *createFlags) validate() error {
|
||||||
if !slices.Contains(pullPolicies, cf.pull) {
|
if !slices.Contains(pullPolicies, cf.pull) {
|
||||||
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
|
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ func TestCreateFlagsValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
||||||
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
input := &NewContainerInput{Platform: "linux/amd64", RunnerOptions: "--pull never", WorkflowOptions: "--platform linux/arm64"}
|
||||||
cr, ok := NewContainer(input).(*containerReference)
|
cr, ok := NewContainer(input).(*containerReference)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, "linux/arm64", input.Platform)
|
assert.Equal(t, "linux/arm64", input.Platform)
|
||||||
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||||
|
|
||||||
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
|
kept := &NewContainerInput{Platform: "linux/amd64", RunnerOptions: "--privileged"}
|
||||||
NewContainer(kept)
|
NewContainer(kept)
|
||||||
assert.Equal(t, "linux/amd64", kept.Platform)
|
assert.Equal(t, "linux/amd64", kept.Platform)
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ type dockerMessage struct {
|
|||||||
Stream string `json:"stream"`
|
Stream string `json:"stream"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
ErrorDetail struct {
|
ErrorDetail struct {
|
||||||
Message string
|
Message string `json:"message"`
|
||||||
}
|
} `json:"errorDetail"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Progress string `json:"progress"`
|
Progress string `json:"progress"`
|
||||||
}
|
}
|
||||||
@@ -60,15 +60,16 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
|||||||
return errors.New(msg.ErrorDetail.Message)
|
return errors.New(msg.ErrorDetail.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Status != "" {
|
switch {
|
||||||
|
case msg.Status != "":
|
||||||
if msg.Progress != "" {
|
if msg.Progress != "" {
|
||||||
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
||||||
} else {
|
} else {
|
||||||
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
|
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
|
||||||
}
|
}
|
||||||
} else if msg.Stream != "" {
|
case msg.Stream != "":
|
||||||
writeLog(logger, isError, "%s", msg.Stream)
|
writeLog(logger, isError, "%s", msg.Stream)
|
||||||
} else {
|
default:
|
||||||
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,17 +35,17 @@ const (
|
|||||||
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
||||||
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
||||||
// while this runs cannot lose the network it has created but not yet attached a container to.
|
// while this runs cannot lose the network it has created but not yet attached a container to.
|
||||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
func RemoveOrphanNetworks(ctx context.Context, runnerUUID, detach string, createdBefore time.Time) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore)
|
return removeOrphanNetworks(ctx, cli, runnerUUID, detach, createdBefore)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error {
|
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID, detach string, createdBefore time.Time) error {
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
||||||
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||||
})
|
})
|
||||||
@@ -51,6 +54,7 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
|
|||||||
}
|
}
|
||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
|
networks:
|
||||||
for _, n := range networks.Items {
|
for _, n := range networks.Items {
|
||||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,9 +63,18 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
|
|||||||
}
|
}
|
||||||
// the emptiness check, not the label, is what keeps a live job of another process
|
// the emptiness check, not the label, is what keeps a live job of another process
|
||||||
// sharing this registration safe
|
// sharing this registration safe
|
||||||
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) {
|
if len(result.Network.Containers) > 1 || result.Network.Created.After(createdBefore) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
for containerID := range result.Network.Containers {
|
||||||
|
if detach == "" || !strings.HasPrefix(containerID, detach) {
|
||||||
|
continue networks
|
||||||
|
}
|
||||||
|
if err := disconnectNetwork(ctx, cli, n.ID, containerID); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to disconnect container from network %s: %w", n.Name, err))
|
||||||
|
continue networks
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||||
continue
|
continue
|
||||||
@@ -134,6 +147,50 @@ func isAddressPoolExhausted(err error) bool {
|
|||||||
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkConnectExecutor(networkName, container string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
if err := connectNetwork(ctx, cli, networkName, container); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("cannot connect %s to network %s, so jobs on it cannot reach the cache: %v", container, networkName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
|
||||||
|
_, err := cli.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
|
||||||
|
Container: container,
|
||||||
|
EndpointConfig: &network.EndpointSettings{Aliases: []string{container}},
|
||||||
|
})
|
||||||
|
if err != nil && (strings.Contains(err.Error(), "already exists in network") || strings.Contains(err.Error(), "already connected")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkDisconnectExecutor(networkName, container string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
return disconnectNetwork(ctx, cli, networkName, container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
|
||||||
|
_, err := cli.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{Container: container, Force: true})
|
||||||
|
if cerrdefs.IsNotFound(err) || (err != nil && strings.Contains(err.Error(), "is not connected")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
@@ -172,3 +229,49 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsolatedCacheContainer returns the container holding addr, when jobs on jobNetwork cannot reach it there.
|
||||||
|
func IsolatedCacheContainer(ctx context.Context, addr netip.Addr, jobNetwork string) (string, error) {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
return isolatedCacheContainer(ctx, cli, addr, jobNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isolatedCacheContainer(ctx context.Context, cli client.APIClient, addr netip.Addr, jobNetwork string) (string, error) {
|
||||||
|
if jobNetwork == "host" || strings.HasPrefix(jobNetwork, "container:") {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var holderID, holderContainer string
|
||||||
|
for _, summary := range containers.Items {
|
||||||
|
if summary.NetworkSettings == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, endpoint := range summary.NetworkSettings.Networks {
|
||||||
|
if endpoint != nil && (endpoint.IPAddress == addr || endpoint.GlobalIPv6Address == addr) {
|
||||||
|
holderID = endpoint.NetworkID
|
||||||
|
holderContainer = summary.ID[:12]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if holderID == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
holder, err := cli.NetworkInspect(ctx, holderID, client.NetworkInspectOptions{})
|
||||||
|
if err != nil || holder.Network.Driver != "bridge" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if jobNetwork != "" {
|
||||||
|
job, err := cli.NetworkInspect(ctx, jobNetwork, client.NetworkInspectOptions{})
|
||||||
|
if err != nil || job.Network.ID == holderID {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return holderContainer, nil
|
||||||
|
}
|
||||||
@@ -6,25 +6,95 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestIsolatedCacheContainer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
|
||||||
|
ID: "0123456789abcdef",
|
||||||
|
NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{
|
||||||
|
"compose": {NetworkID: "c0ffee", IPAddress: cache},
|
||||||
|
"lan": {NetworkID: "beef", IPAddress: lan},
|
||||||
|
}},
|
||||||
|
}}}, nil)
|
||||||
|
for ref, inspected := range map[string]network.Network{
|
||||||
|
"c0ffee": {ID: "c0ffee", Name: "compose", Driver: "bridge"},
|
||||||
|
"beef": {ID: "beef", Name: "lan", Driver: "macvlan"},
|
||||||
|
"c0f": {ID: "c0ffee"},
|
||||||
|
"jobs": {ID: "f00d"},
|
||||||
|
} {
|
||||||
|
client.On("NetworkInspect", ctx, ref, mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: inspected}}, nil)
|
||||||
|
}
|
||||||
|
check := func(addr netip.Addr, jobNetwork, want string) {
|
||||||
|
got, err := isolatedCacheContainer(ctx, client, addr, jobNetwork)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got, jobNetwork)
|
||||||
|
}
|
||||||
|
check(cache, "", "0123456789ab")
|
||||||
|
check(cache, "jobs", "0123456789ab")
|
||||||
|
check(cache, "c0f", "")
|
||||||
|
check(cache, "host", "")
|
||||||
|
check(lan, "", "")
|
||||||
|
check(netip.MustParseAddr("10.0.0.5"), "", "")
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsAddressPoolExhausted(t *testing.T) {
|
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||||
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||||
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||||
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
|
func (m *mockDockerClient) NetworkConnect(ctx context.Context, id string, opts mobyclient.NetworkConnectOptions) (mobyclient.NetworkConnectResult, error) {
|
||||||
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||||
// test on its own, since testify has no expectation to match it against.
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkDisconnect(ctx context.Context, id string, opts mobyclient.NetworkDisconnectOptions) (mobyclient.NetworkDisconnectResult, error) {
|
||||||
|
return mobyclient.NetworkDisconnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectAndDisconnectNetwork(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
connect := func(answer error) error {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
|
||||||
|
Container: "0123456789ab",
|
||||||
|
EndpointConfig: &network.EndpointSettings{Aliases: []string{"0123456789ab"}},
|
||||||
|
}).Return(answer).Once()
|
||||||
|
return connectNetwork(ctx, client, "jobs", "0123456789ab")
|
||||||
|
}
|
||||||
|
require.NoError(t, connect(nil))
|
||||||
|
require.NoError(t, connect(errors.New("endpoint with name runner already exists in network jobs")))
|
||||||
|
require.NoError(t, connect(errors.New("container runner already connected to network jobs")))
|
||||||
|
require.ErrorIs(t, connect(context.Canceled), context.Canceled)
|
||||||
|
|
||||||
|
disconnect := func(answer error) error {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkDisconnect", ctx, "jobs", mobyclient.NetworkDisconnectOptions{
|
||||||
|
Container: "0123456789ab", Force: true,
|
||||||
|
}).Return(answer).Once()
|
||||||
|
return disconnectNetwork(ctx, client, "jobs", "0123456789ab")
|
||||||
|
}
|
||||||
|
require.NoError(t, disconnect(cerrdefs.ErrNotFound))
|
||||||
|
require.NoError(t, disconnect(errors.New("container runner is not connected to the network jobs")))
|
||||||
|
require.ErrorIs(t, disconnect(context.Canceled), context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of this runner's networks, only the ones nothing but its own cache container is attached to
|
||||||
|
// and old enough to predate any job now starting are the runner's to reclaim. An unexpected
|
||||||
|
// NetworkRemove fails the test on its own, since testify has no expectation to match it against.
|
||||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
@@ -32,19 +102,32 @@ func TestRemoveOrphanNetworks(t *testing.T) {
|
|||||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||||
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
||||||
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||||
{Network: network.Network{ID: "orphan"}},
|
{ID: "orphan"},
|
||||||
{Network: network.Network{ID: "busy"}},
|
{ID: "busy"},
|
||||||
{Network: network.Network{ID: "starting"}},
|
{ID: "starting"},
|
||||||
|
{ID: "cached"},
|
||||||
|
{ID: "shared"},
|
||||||
}}, nil)
|
}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||||
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{
|
||||||
|
Network: network.Network{Created: cutoff.Add(time.Second)},
|
||||||
|
Containers: map[string]network.EndpointResource{"0123456789abcdef": {}},
|
||||||
|
}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "cached", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}}}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "shared", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}, "job": {}}}}, nil)
|
||||||
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||||
|
client.On("NetworkDisconnect", ctx, "cached", mobyclient.NetworkDisconnectOptions{Container: "0123456789abcdef", Force: true}).
|
||||||
|
Return(nil).Once()
|
||||||
|
client.On("NetworkRemove", ctx, "cached", mobyclient.NetworkRemoveOptions{}).
|
||||||
|
Return(mobyclient.NetworkRemoveResult{}, nil).Once()
|
||||||
|
|
||||||
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff))
|
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", "0123456789ab", cutoff))
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyDockerSocketPermissions(daemonSocket, socket string, info os.FileInfo) error {
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("docker socket ownership is unavailable")
|
||||||
|
}
|
||||||
|
groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid))
|
||||||
|
if groupErr != nil && (!errors.Is(groupErr, fs.ErrPermission) || int(stat.Uid) != os.Geteuid()) {
|
||||||
|
return groupErr
|
||||||
|
}
|
||||||
|
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := uint16(info.Mode().Perm())
|
||||||
|
if err := os.Chmod(socket, fs.FileMode(mode)); err != nil || groupErr == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if size, err := unix.Getxattr(daemonSocket, "system.posix_acl_access", nil); err == nil && size > 0 {
|
||||||
|
return groupErr
|
||||||
|
}
|
||||||
|
owner, group, other := mode>>6, mode>>3&7, mode&7
|
||||||
|
acl := binary.LittleEndian.AppendUint32(nil, 2)
|
||||||
|
for _, entry := range []struct {
|
||||||
|
tag, perm uint16
|
||||||
|
id uint32
|
||||||
|
}{{1, owner, math.MaxUint32}, {4, other, math.MaxUint32}, {8, group, stat.Gid}, {16, group | other, math.MaxUint32}, {32, other, math.MaxUint32}} {
|
||||||
|
acl = binary.LittleEndian.AppendUint32(binary.LittleEndian.AppendUint16(binary.LittleEndian.AppendUint16(acl, entry.tag), entry.perm), entry.id)
|
||||||
|
}
|
||||||
|
return unix.Setxattr(socket, "system.posix_acl_access", acl, 0) // names the group a rootless runner cannot chown to, its own group keeps what others had
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error {
|
||||||
|
return errors.New("docker socket ownership cannot be preserved on Windows")
|
||||||
|
}
|
||||||
+275
-275
File diff suppressed because it is too large.
Load diff
+261
-213
File diff suppressed because it is too large.
Load diff
@@ -1,138 +0,0 @@
|
|||||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
var CommonSocketLocations = []string{
|
|
||||||
"/var/run/docker.sock",
|
|
||||||
"/run/podman/podman.sock",
|
|
||||||
"$HOME/.colima/docker.sock",
|
|
||||||
"$XDG_RUNTIME_DIR/docker.sock",
|
|
||||||
"$XDG_RUNTIME_DIR/podman/podman.sock",
|
|
||||||
`\\.\pipe\docker_engine`,
|
|
||||||
"$HOME/.docker/run/docker.sock",
|
|
||||||
}
|
|
||||||
|
|
||||||
// returns socket URI or false if not found any
|
|
||||||
func socketLocation() (string, bool) {
|
|
||||||
if dockerHost, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
|
||||||
return dockerHost, true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range CommonSocketLocations {
|
|
||||||
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
|
|
||||||
if strings.HasPrefix(p, `\\.\`) {
|
|
||||||
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
|
||||||
}
|
|
||||||
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// This function, `isDockerHostURI`, takes a string argument `daemonPath`. It checks if the
|
|
||||||
// `daemonPath` is a valid Docker host URI. It does this by checking if the scheme of the URI (the
|
|
||||||
// part before "://") contains only alphabetic characters. If it does, the function returns true,
|
|
||||||
// indicating that the `daemonPath` is a Docker host URI. If it doesn't, or if the "://" delimiter
|
|
||||||
// is not found in the `daemonPath`, the function returns false.
|
|
||||||
func isDockerHostURI(daemonPath string) bool {
|
|
||||||
if before, _, ok := strings.Cut(daemonPath, "://"); ok {
|
|
||||||
scheme := before
|
|
||||||
if strings.IndexFunc(scheme, func(r rune) bool {
|
|
||||||
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z')
|
|
||||||
}) == -1 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type SocketAndHost struct {
|
|
||||||
Socket string
|
|
||||||
Host string
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetSocketAndHost(containerSocket string) (SocketAndHost, error) {
|
|
||||||
log.Debugf("Handling container host and socket")
|
|
||||||
|
|
||||||
// Prefer DOCKER_HOST, don't override it
|
|
||||||
dockerHost, hasDockerHost := socketLocation()
|
|
||||||
socketHost := SocketAndHost{Socket: containerSocket, Host: dockerHost}
|
|
||||||
|
|
||||||
// ** socketHost.Socket cases **
|
|
||||||
// Case 1: User does _not_ want to mount a daemon socket (passes a dash)
|
|
||||||
// Case 2: User passes a filepath to the socket; is that even valid?
|
|
||||||
// Case 3: User passes a valid socket; do nothing
|
|
||||||
// Case 4: User omitted the flag; set a sane default
|
|
||||||
|
|
||||||
// ** DOCKER_HOST cases **
|
|
||||||
// Case A: DOCKER_HOST is set; use it, i.e. do nothing
|
|
||||||
// Case B: DOCKER_HOST is empty; use sane defaults
|
|
||||||
|
|
||||||
// Set host for sanity's sake, when the socket isn't useful
|
|
||||||
if !hasDockerHost && (socketHost.Socket == "-" || !isDockerHostURI(socketHost.Socket) || socketHost.Socket == "") {
|
|
||||||
// Cases: 1B, 2B, 4B
|
|
||||||
socket, found := socketLocation()
|
|
||||||
socketHost.Host = socket
|
|
||||||
hasDockerHost = found
|
|
||||||
}
|
|
||||||
|
|
||||||
// A - (dash) in socketHost.Socket means don't mount, preserve this value
|
|
||||||
// otherwise if socketHost.Socket is a filepath don't use it as socket
|
|
||||||
// Exit early if we're in an invalid state (e.g. when no DOCKER_HOST and user supplied "-", a dash or omitted)
|
|
||||||
if !hasDockerHost && socketHost.Socket != "" && !isDockerHostURI(socketHost.Socket) {
|
|
||||||
// Cases: 1B, 2B
|
|
||||||
// Should we early-exit here, since there is no host nor socket to talk to?
|
|
||||||
return SocketAndHost{}, fmt.Errorf("DOCKER_HOST was not set, couldn't be found in the usual locations, and the container daemon socket ('%s') is invalid", socketHost.Socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to DOCKER_HOST if set
|
|
||||||
if socketHost.Socket == "" && hasDockerHost {
|
|
||||||
// Cases: 4A
|
|
||||||
log.Debugf("Defaulting container socket to DOCKER_HOST")
|
|
||||||
socketHost.Socket = socketHost.Host
|
|
||||||
}
|
|
||||||
// Set sane default socket location if user omitted it
|
|
||||||
if socketHost.Socket == "" {
|
|
||||||
// Cases: 4B
|
|
||||||
socket, _ := socketLocation()
|
|
||||||
// socket is empty if it isn't found, so assignment here is at worst a no-op
|
|
||||||
log.Debugf("Defaulting container socket to default '%s'", socket)
|
|
||||||
socketHost.Socket = socket
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exit if both the DOCKER_HOST and socket are fulfilled
|
|
||||||
if hasDockerHost {
|
|
||||||
// Cases: 1A, 2A, 3A, 4A
|
|
||||||
if !isDockerHostURI(socketHost.Socket) {
|
|
||||||
// Cases: 1A, 2A
|
|
||||||
log.Debugf("DOCKER_HOST is set, but socket is invalid '%s'", socketHost.Socket)
|
|
||||||
}
|
|
||||||
return socketHost, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a sane DOCKER_HOST default if we can
|
|
||||||
if isDockerHostURI(socketHost.Socket) {
|
|
||||||
// Cases: 3B
|
|
||||||
log.Debugf("Setting DOCKER_HOST to container socket '%s'", socketHost.Socket)
|
|
||||||
socketHost.Host = socketHost.Socket
|
|
||||||
// Both DOCKER_HOST and container socket are valid; short-circuit exit
|
|
||||||
return socketHost, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Here there is no DOCKER_HOST _and_ the supplied container socket is not a valid URI (either invalid or a file path)
|
|
||||||
// Cases: 2B <- but is already handled at the top
|
|
||||||
// I.e. this path should never be taken
|
|
||||||
return SocketAndHost{}, fmt.Errorf("no DOCKER_HOST and an invalid container socket '%s'", socketHost.Socket)
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
||||||
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
log.SetLevel(log.DebugLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
var originalCommonSocketLocations = CommonSocketLocations
|
|
||||||
|
|
||||||
func isolateSocketEnv(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
|
|
||||||
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
|
|
||||||
t.Setenv("DOCKER_HOST", host)
|
|
||||||
} else {
|
|
||||||
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
|
||||||
socketURI := "/path/to/my.socket"
|
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost(socketURI)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostNoSocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost("")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
socketURI := "/path/to/my.socket"
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
defaultSocket, defaultSocketFound := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost(socketURI)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.True(t, defaultSocketFound, "Expected to find default socket")
|
|
||||||
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
|
|
||||||
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost("-")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
defaultSocket, found := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost("")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.True(t, found, "Expected a default socket to be found")
|
|
||||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catch
|
|
||||||
// > Your code breaks setting DOCKER_HOST if shouldMount is false.
|
|
||||||
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
|
||||||
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
|
||||||
mySocket := mySocketFile.Name()
|
|
||||||
unixSocket := "unix://" + mySocket
|
|
||||||
defer os.RemoveAll(mySocket)
|
|
||||||
assert.NoError(t, tmpErr) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
|
|
||||||
CommonSocketLocations = []string{mySocket}
|
|
||||||
defaultSocket, found := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost("")
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
|
|
||||||
assert.True(t, found, "Expected default socket to be found")
|
|
||||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
mySocket := "/my/socket/path.sock"
|
|
||||||
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
|
||||||
defaultSocket, found := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost(mySocket)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert.False(t, found, "Expected no default socket to be found")
|
|
||||||
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
|
|
||||||
assert.Error(t, err, "Expected an error in invalid state")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
|
||||||
// Arrange
|
|
||||||
isolateSocketEnv(t)
|
|
||||||
socketURI := "unix:///path/to/my.socket"
|
|
||||||
CommonSocketLocations = []string{"/unusual", "/location"}
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
|
||||||
defaultSocket, found := socketLocation()
|
|
||||||
|
|
||||||
// Act
|
|
||||||
ret, err := GetSocketAndHost(socketURI)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
// Default socket locations
|
|
||||||
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.False(t, found, "Expected no default socket to be found")
|
|
||||||
// Sane default
|
|
||||||
assert.NoError(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,10 @@ import (
|
|||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func IsolatedCacheContainer(context.Context, netip.Addr, string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
// requested name, tag and architecture exists in the local docker image store
|
// requested name, tag and architecture exists in the local docker image store
|
||||||
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||||
@@ -29,6 +34,18 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
|||||||
return false, errors.New("Unsupported Operation")
|
return false, errors.New("Unsupported Operation")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RemoveDockerJobResources(_ context.Context, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerProxy(_ context.Context, _ string) *DockerProxy {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||||
|
return nil, errors.New("Unsupported Operation")
|
||||||
|
}
|
||||||
|
|
||||||
// NewDockerBuildExecutor function to create a run executor for the container
|
// NewDockerBuildExecutor function to create a run executor for the container
|
||||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -74,6 +91,26 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
func NewDockerNetworkConnectExecutor(_, _ string) common.Executor {
|
||||||
|
return func(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkDisconnectExecutor(_, _ string) common.Executor {
|
||||||
|
return func(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanNetworks(_ context.Context, _, _ string, _ time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -8,40 +8,67 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||||
return func(ctx context.Context) error {
|
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
for _, volumeName := range volumeNames {
|
||||||
|
if _, err := cli.VolumeCreate(ctx, client.VolumeCreateOptions{
|
||||||
|
Name: volumeName,
|
||||||
|
Labels: runnerLabels(runnerUUID),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{
|
||||||
|
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID).Add("dangling", "true"),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, vol := range list.Items {
|
var errs []error
|
||||||
if vol.Name == volumeName {
|
for _, item := range volumes.Items {
|
||||||
return removeExecutor(volumeName, force)(ctx)
|
created, err := time.Parse(time.RFC3339, item.CreatedAt)
|
||||||
|
// an unreadable or recent timestamp may belong to a job that is still starting up
|
||||||
|
if err != nil || created.After(createdBefore) || item.Labels[runnerUUIDLabel] != runnerUUID {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if _, err := cli.VolumeRemove(ctx, item.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", item.Name, err))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
common.Logger(ctx).Infof("removed docker volume %s left behind by an earlier job", item.Name)
|
||||||
// Volume not found - do nothing
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeExecutor(volume string, force bool) common.Executor {
|
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
common.Logger(ctx).Debugf("docker volume rm %s", volumeName)
|
||||||
logger.Debugf("docker volume rm %s", volume)
|
|
||||||
|
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -53,7 +80,10 @@ func removeExecutor(volume string, force bool) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
_, err = cli.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
|
||||||
|
if cerrdefs.IsNotFound(err) { // already gone is the outcome we wanted
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/lookpath"
|
"gitea.com/gitea/runner/act/lookpath"
|
||||||
"gitea.com/gitea/runner/internal/pkg/process"
|
"gitea.com/gitea/runner/internal/pkg/process"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||||
"github.com/go-git/go-billy/v5/osfs"
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
@@ -71,12 +72,6 @@ func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) ConnectToNetwork(name string) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *HostEnvironment) Close() common.Executor {
|
func (e *HostEnvironment) Close() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
@@ -97,34 +92,7 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
if err := os.RemoveAll(destPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tr := tar.NewReader(tarStream)
|
|
||||||
cp := &filecollector.CopyCollector{
|
|
||||||
DstDir: destPath,
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
ti, err := tr.Next()
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
return nil
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ti.FileInfo().IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return errors.New("CopyTarStream has been cancelled")
|
|
||||||
}
|
|
||||||
if err := cp.WriteFile(ti.Name, ti.FileInfo(), ti.Linkname, tr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
srcPrefix := filepath.Dir(srcPath)
|
srcPrefix := filepath.Dir(srcPath)
|
||||||
@@ -142,10 +110,10 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
|
|||||||
ignorer = gitignore.NewMatcher(ps)
|
ignorer = gitignore.NewMatcher(ps)
|
||||||
}
|
}
|
||||||
fc := &filecollector.FileCollector{
|
fc := &filecollector.FileCollector{
|
||||||
Fs: &filecollector.DefaultFs{},
|
|
||||||
Ignorer: ignorer,
|
Ignorer: ignorer,
|
||||||
SrcPath: srcPath,
|
SrcPath: srcPath,
|
||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
|
SkipGitDir: skipGitDir,
|
||||||
Handler: &filecollector.CopyCollector{
|
Handler: &filecollector.CopyCollector{
|
||||||
DstDir: destPath,
|
DstDir: destPath,
|
||||||
},
|
},
|
||||||
@@ -154,6 +122,14 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) DumpLogs(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostEnvironment) Inspect(_ context.Context) (*Info, error) {
|
||||||
|
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
tw := tar.NewWriter(buf)
|
tw := tar.NewWriter(buf)
|
||||||
@@ -172,7 +148,6 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
|
|||||||
srcPrefix += string(filepath.Separator)
|
srcPrefix += string(filepath.Separator)
|
||||||
}
|
}
|
||||||
fc := &filecollector.FileCollector{
|
fc := &filecollector.FileCollector{
|
||||||
Fs: &filecollector.DefaultFs{},
|
|
||||||
SrcPath: srcPath,
|
SrcPath: srcPath,
|
||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
Handler: tc,
|
Handler: tc,
|
||||||
@@ -238,24 +213,8 @@ func (w *ptyWriter) Write(buf []byte) (int, error) {
|
|||||||
return w.Out.Write(buf)
|
return w.Out.Write(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
type localEnv struct {
|
|
||||||
env map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *localEnv) Getenv(name string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
for k, v := range l.env {
|
|
||||||
if strings.EqualFold(name, k) {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return l.env[name]
|
|
||||||
}
|
|
||||||
|
|
||||||
func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) {
|
func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) {
|
||||||
f, err := lookpath.LookPath2(cmd, &localEnv{env: env})
|
f, err := lookpath.LookPath2(cmd, env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err := "Cannot find: " + cmd + " in PATH"
|
err := "Cannot find: " + cmd + " in PATH"
|
||||||
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
|
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
|
||||||
@@ -267,7 +226,7 @@ func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||||
ppty, tty, err := openPty()
|
ppty, tty, err := pty.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -393,8 +352,7 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
}
|
}
|
||||||
err = cmd.Wait()
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var exitErr *exec.ExitError
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
if errors.As(err, &exitErr) {
|
|
||||||
return ExitCodeError(exitErr.ExitCode())
|
return ExitCodeError(exitErr.ExitCode())
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -672,8 +630,11 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string {
|
|||||||
func goArchToActionArch(arch string) string {
|
func goArchToActionArch(arch string) string {
|
||||||
archMapper := map[string]string{
|
archMapper := map[string]string{
|
||||||
"x86_64": "X64",
|
"x86_64": "X64",
|
||||||
|
"amd64": "X64",
|
||||||
"386": "X86",
|
"386": "X86",
|
||||||
|
"arm": "ARM",
|
||||||
"aarch64": "ARM64",
|
"aarch64": "ARM64",
|
||||||
|
"arm64": "ARM64",
|
||||||
}
|
}
|
||||||
if arch, ok := archMapper[arch]; ok {
|
if arch, ok := archMapper[arch]; ok {
|
||||||
return arch
|
return arch
|
||||||
@@ -683,7 +644,9 @@ func goArchToActionArch(arch string) string {
|
|||||||
|
|
||||||
func goOsToActionOs(os string) string {
|
func goOsToActionOs(os string) string {
|
||||||
osMapper := map[string]string{
|
osMapper := map[string]string{
|
||||||
|
"linux": "Linux",
|
||||||
"darwin": "macOS",
|
"darwin": "macOS",
|
||||||
|
"windows": "Windows",
|
||||||
}
|
}
|
||||||
if os, ok := osMapper[os]; ok {
|
if os, ok := osMapper[os]; ok {
|
||||||
return os
|
return os
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import (
|
|||||||
// Type assert HostEnvironment implements ExecutionsEnvironment
|
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||||
var _ ExecutionsEnvironment = &HostEnvironment{}
|
var _ ExecutionsEnvironment = &HostEnvironment{}
|
||||||
|
|
||||||
|
func TestActionPlatformNames(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Linux", "macOS", "Windows"}, []string{goOsToActionOs("linux"), goOsToActionOs("darwin"), goOsToActionOs("windows")})
|
||||||
|
assert.Equal(t, []string{"X86", "X64", "ARM", "ARM64"}, []string{goArchToActionArch("386"), goArchToActionArch("amd64"), goArchToActionArch("arm"), goArchToActionArch("arm64")})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCopyDir(t *testing.T) {
|
func TestCopyDir(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -42,10 +47,32 @@ func TestCopyDir(t *testing.T) {
|
|||||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||||
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
|
err := e.CopyDir(e.Workdir, e.Path, true, false)(ctx)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyDirSkipGitDir(t *testing.T) {
|
||||||
|
for name, skipGitDir := range map[string]bool{"kept for a workspace copy": false, "skipped for an action copy": true} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
src := filepath.Join(t.TempDir(), "action")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
|
||||||
|
|
||||||
|
dest := filepath.Join(t.TempDir(), "dest")
|
||||||
|
e := &HostEnvironment{StdOut: os.Stdout}
|
||||||
|
require.NoError(t, e.CopyDir(dest, src+string(filepath.Separator), false, skipGitDir)(context.Background()))
|
||||||
|
|
||||||
|
assert.FileExists(t, filepath.Join(dest, "action.yml"))
|
||||||
|
if skipGitDir {
|
||||||
|
assert.NoDirExists(t, filepath.Join(dest, ".git"))
|
||||||
|
} else {
|
||||||
|
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetContainerArchive(t *testing.T) {
|
func TestGetContainerArchive(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
Loaded 50 of 252 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user