mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-12 19:03:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a8ee4ce57 | ||
|
|
bf03f36d5a | ||
|
|
5a38a3d0c4 | ||
|
|
6618e67645 | ||
|
|
2b599fae7f | ||
|
|
990ebaa2ac | ||
|
|
81add27459 | ||
|
|
ace8653105 | ||
|
|
95daa50d9b | ||
|
|
1952e5cdb6 | ||
|
|
7ad12038d1 | ||
|
|
98816a605c | ||
|
|
e0d00e543d | ||
|
|
ae9ce10f71 | ||
|
|
3962d0d970 | ||
|
|
d83fa77de4 | ||
|
|
58ed5921f3 | ||
|
|
00ff71a879 | ||
|
|
80db5f61ac | ||
|
|
5310d8119a | ||
|
|
b9c6305dee | ||
|
|
699a97fc8c | ||
|
|
19afebc53f | ||
|
|
2cc3000369 | ||
|
|
3d116eb0c2 | ||
|
|
e0ce6776c7 | ||
|
|
69d5c22300 | ||
|
|
54978255f5 | ||
|
|
498282caaa | ||
|
|
2ed8cdb76e | ||
|
|
ff9965e940 | ||
|
|
ba4d3c5b4f | ||
|
|
9e3647395a | ||
|
|
c158ac5472 | ||
|
|
1745c7c841 | ||
|
|
7f53eca716 | ||
|
|
fca6b44c60 | ||
|
|
b9018aca31 | ||
|
|
6c77065295 | ||
|
|
235c035003 | ||
|
|
12dc9d26a2 | ||
|
|
d9f4d65545 | ||
|
|
212909db7b | ||
|
|
0712b2a7a1 | ||
|
|
34df4887af | ||
|
|
745a1e70e6 | ||
|
|
e30c2fed62 | ||
|
|
7b4356c746 | ||
|
|
e78123cee3 | ||
|
|
8260e2def2 | ||
|
|
b97c61aa14 | ||
|
|
546eca312e | ||
|
|
a0c4de79f7 | ||
|
|
dfe979e1d0 | ||
|
|
11ac12efa4 | ||
|
|
bc8161c673 | ||
|
|
3f70822458 | ||
|
|
be90c01468 | ||
|
|
6c6a878403 | ||
|
|
f97680a68d | ||
|
|
dbd9a892f8 | ||
|
|
e178c03adc | ||
|
|
b66433e667 | ||
|
|
a8dcd5b67c | ||
|
|
da9b559fb5 | ||
|
|
9dd9204937 |
No files matched your search
@@ -20,11 +20,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
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
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
# has already been created. Fail here instead, before anything
|
||||
# is built or published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
@@ -43,11 +42,6 @@ jobs:
|
||||
args: release --nightly
|
||||
env:
|
||||
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_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
@@ -80,10 +74,10 @@ jobs:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
@@ -100,7 +94,7 @@ jobs:
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -12,11 +12,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
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
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
# has already been created. Fail here instead, before anything
|
||||
# is built or published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
@@ -42,11 +41,6 @@ jobs:
|
||||
args: release
|
||||
env:
|
||||
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_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
@@ -83,10 +77,10 @@ jobs:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
@@ -109,7 +103,7 @@ jobs:
|
||||
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -5,17 +5,18 @@ on:
|
||||
- main
|
||||
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:
|
||||
lint:
|
||||
name: check and test
|
||||
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:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
@@ -28,9 +29,16 @@ jobs:
|
||||
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
|
||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||
- name: pre-pull test images
|
||||
env:
|
||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
|
||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # renovate: datasource=docker
|
||||
run: |
|
||||
for img in node:24-bookworm-slim nginx:alpine; do
|
||||
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
|
||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||
for attempt in 1 2 3; do
|
||||
docker pull "$image" && break
|
||||
[ "$attempt" = 3 ] || sleep 5
|
||||
done
|
||||
docker tag "$image" "${image%@*}"
|
||||
done
|
||||
- name: lint
|
||||
run: make lint
|
||||
@@ -45,7 +53,28 @@ jobs:
|
||||
# after `make test` so the images it needs are already present on the host daemon.
|
||||
- name: test against dind image
|
||||
run: make test-dind
|
||||
- name: test against podman
|
||||
run: make test-dind TARGET=podman
|
||||
- name: coverage report
|
||||
run: |
|
||||
make coverage-report
|
||||
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:
|
||||
enabled-checks:
|
||||
- equalFold
|
||||
disabled-checks:
|
||||
- ifElseChain
|
||||
disabled-checks: []
|
||||
revive:
|
||||
severity: error
|
||||
rules:
|
||||
@@ -71,10 +70,14 @@ linters:
|
||||
- name: unexported-return
|
||||
- name: var-declaration
|
||||
- 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:
|
||||
checks:
|
||||
- all
|
||||
- -ST1005
|
||||
testifylint: {}
|
||||
usetesting:
|
||||
os-temp-dir: true
|
||||
perfsprint:
|
||||
@@ -92,8 +95,6 @@ linters:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
- linters:
|
||||
@@ -118,7 +119,8 @@ formatters:
|
||||
- blank
|
||||
- default
|
||||
gofumpt:
|
||||
extra-rules: true
|
||||
extra:
|
||||
group-params: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
run:
|
||||
|
||||
+7
-19
@@ -83,24 +83,12 @@ builds:
|
||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
|
||||
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
|
||||
|
||||
blobs:
|
||||
-
|
||||
provider: s3
|
||||
bucket: "{{ .Env.S3_BUCKET }}"
|
||||
region: "{{ .Env.S3_REGION }}"
|
||||
directory: "gitea-runner/{{.Version}}"
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
|
||||
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
||||
# parallel S3+R2 period (S3 will be removed once migration completes).
|
||||
# A second `blobs:` entry is impossible here since the blob pipe
|
||||
# authenticates from the global AWS_* env with no per-entry
|
||||
# credentials; `publishers:` supports per-entry `env:` instead, so
|
||||
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
||||
# publishers inherit almost nothing from the environment, hence the
|
||||
# explicit R2_* forwarding below.
|
||||
# Uploads every release artifact to Cloudflare R2. The `blobs:` pipe
|
||||
# isn't usable here since it 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
|
||||
# goreleaser's release pipe already registers `release.extra_files`
|
||||
@@ -125,7 +113,7 @@ publishers:
|
||||
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||
|
||||
archives:
|
||||
- format: binary
|
||||
- formats: [binary]
|
||||
name_template: "{{ .Binary }}"
|
||||
allow_different_binary_count: true
|
||||
|
||||
|
||||
@@ -30,3 +30,17 @@ depending on the prefix:
|
||||
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
|
||||
#
|
||||
#
|
||||
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`
|
||||
RUN apk add --no-cache make git
|
||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
||||
### DIND VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.6.2-dind AS dind
|
||||
FROM docker:29.7.2-dind AS dind
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -32,12 +32,14 @@ COPY scripts/s6 /etc/s6
|
||||
|
||||
VOLUME /data
|
||||
|
||||
ENV TINI_SUBREAPER=1
|
||||
|
||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
|
||||
### 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
|
||||
|
||||
@@ -56,6 +58,7 @@ VOLUME /data
|
||||
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
|
||||
|
||||
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
|
||||
ENV TINI_SUBREAPER=1
|
||||
|
||||
USER rootless
|
||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
|
||||
@@ -5,8 +5,13 @@ GO ?= go
|
||||
SHASUM ?= shasum -a 256
|
||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
|
||||
XGO_VERSION := go-1.26.x
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]6 # renovate: datasource=go
|
||||
XGO_VERSION := go-1.27.x
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]7 # renovate: datasource=go
|
||||
|
||||
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
|
||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # renovate: datasource=docker
|
||||
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||
|
||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||
@@ -18,8 +23,8 @@ DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
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
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.6.0 # 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.8.0 # renovate: datasource=go
|
||||
|
||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||
|
||||
@@ -96,13 +101,8 @@ go-check:
|
||||
fi
|
||||
|
||||
.PHONY: fmt-check
|
||||
fmt-check: fmt
|
||||
@diff=$$(git diff --color=always -- '*.go'); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Please run 'make fmt' and commit the result:"; \
|
||||
printf "%s" "$${diff}"; \
|
||||
exit 1; \
|
||||
fi
|
||||
fmt-check: ## check the Go code formatting
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
|
||||
|
||||
.PHONY: deps-tools
|
||||
deps-tools: ## install tool dependencies
|
||||
@@ -137,7 +137,7 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
||||
|
||||
.PHONY: security-check
|
||||
security-check:
|
||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||
$(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||
|
||||
.PHONY: tidy
|
||||
tidy: ## run go mod tidy
|
||||
@@ -149,13 +149,8 @@ tidy: ## run go mod tidy
|
||||
fi
|
||||
|
||||
.PHONY: tidy-check
|
||||
tidy-check: tidy
|
||||
@diff=$$(git diff --color=always -- go.mod go.sum); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Please run 'make tidy' and commit the result:"; \
|
||||
printf "%s" "$${diff}"; \
|
||||
exit 1; \
|
||||
fi
|
||||
tidy-check: ## check that go.mod and go.sum are tidy
|
||||
$(GO) mod tidy -diff
|
||||
|
||||
.PHONY: test
|
||||
test: ## test everything (integration tests self-skip without docker/network)
|
||||
@@ -168,8 +163,14 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||
@echo "Wrote .tmp/coverage.md"
|
||||
|
||||
.PHONY: test-dind
|
||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||
@./scripts/test-dind.sh $(TARGET)
|
||||
test-dind: ## run the daemon-facing tests against the built dind image or podman (TARGET=dind|dind-rootless|podman)
|
||||
@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
|
||||
install: $(GOFILES) ## install the runner binary via `go install`
|
||||
|
||||
@@ -158,6 +158,32 @@ An edit keeps the comments and the key order of the file. Indentation becomes tw
|
||||
|
||||
`config get`, `set`, `add` and `remove` use `config.yaml` (or `config.yml`) from the working directory, then from the directory of the binary, and print their choice to stderr. `config init` writes `config.yaml` in the working directory, and refuses to overwrite an existing config without `--force`. Pass `-c` for another path.
|
||||
|
||||
#### 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.
|
||||
@@ -202,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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -240,6 +266,17 @@ A job in a container reaches a service by its id on the job network, on the port
|
||||
|
||||
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
|
||||
|
||||
#### 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
|
||||
|
||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||
@@ -254,14 +291,14 @@ The runner uses them for its own requests and gives them to every job, in lower
|
||||
|
||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||
|
||||
- the cache server
|
||||
- the built-in cache server, whose address is assigned at startup
|
||||
- `localhost`, `127.0.0.1` and `::1`
|
||||
- the job's service containers
|
||||
- the Docker daemon, when it is reached over `tcp://`
|
||||
|
||||
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
||||
|
||||
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||
For one step, set it in the step's `env:`, for the rest of a job, write it to `$GITHUB_ENV`. Setting it at workflow or job level or in `container.env` has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||
|
||||
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
||||
|
||||
@@ -269,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.
|
||||
|
||||
#### 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`)
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
```
|
||||
`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.
|
||||
|
||||
2. Start the server:
|
||||
|
||||
```bash
|
||||
gitea-runner -c cache-server-config.yaml cache-server
|
||||
```
|
||||
|
||||
3. On every runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
external_server: "http://<cache-server-host>:8088/"
|
||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
||||
# 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.
|
||||
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.
|
||||
|
||||
#### Official Docker image
|
||||
|
||||
@@ -352,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.
|
||||
|
||||
#### 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
|
||||
|
||||
Check out the [examples](examples) directory for sample deployment types.
|
||||
+517
-147
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 (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"encoding/json/v2"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -38,8 +39,9 @@ const (
|
||||
|
||||
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) {
|
||||
@@ -55,7 +57,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
} 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)
|
||||
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()
|
||||
cache := &Cache{
|
||||
Repo: cred.Repo,
|
||||
Key: req.Key,
|
||||
Version: req.Version,
|
||||
Owner: owner,
|
||||
Size: -1, // the size is only known at finalize time
|
||||
CreatedAt: now,
|
||||
UsedAt: now,
|
||||
}
|
||||
if err := insertCache(db, cache); err != nil {
|
||||
if err := h.insertCache(db, cache); err != nil {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"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())
|
||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,6 +139,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
||||
defer db.Close()
|
||||
|
||||
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 {
|
||||
h.twirpError(w, r, twirpInternal, err)
|
||||
return
|
||||
@@ -125,13 +154,15 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
db.Close() // commitCache needs the store closed
|
||||
|
||||
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
|
||||
if err := h.commitCache(cache); err != nil {
|
||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
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 {
|
||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||
h.twirpNotOK(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
@@ -145,7 +176,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
||||
cred := credFromContext(r.Context())
|
||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||
if err != nil {
|
||||
h.twirpError(w, r, "malformed_request", err)
|
||||
h.twirpError(w, r, "malformed", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -168,7 +199,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||
"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,
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||
status := http.StatusBadRequest
|
||||
if code == twirpInternal {
|
||||
switch code {
|
||||
case twirpInternal:
|
||||
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()})
|
||||
}
|
||||
@@ -244,10 +280,10 @@ type (
|
||||
}
|
||||
|
||||
v2FinalizeRequest struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
SizeBytes json.Number `json:"size_bytes"`
|
||||
SizeBytesCamel json.Number `json:"sizeBytes"`
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
SizeBytes twirpInt64 `json:"size_bytes"`
|
||||
SizeBytesCamel twirpInt64 `json:"sizeBytes"`
|
||||
}
|
||||
|
||||
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 {
|
||||
restoreKeys := d.RestoreKeys
|
||||
if len(restoreKeys) == 0 {
|
||||
@@ -268,6 +329,6 @@ func (d v2DownloadRequest) keys() []string {
|
||||
|
||||
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
|
||||
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
|
||||
}
|
||||
@@ -6,12 +6,13 @@ package artifactcache
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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)
|
||||
|
||||
got := map[string]any{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
|
||||
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||
return got
|
||||
}
|
||||
|
||||
@@ -66,16 +67,6 @@ func getURL(t *testing.T, url string) []byte {
|
||||
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
|
||||
// with the upload URL it used.
|
||||
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
|
||||
// or to replace a finalized entry.
|
||||
func TestCacheServiceV2RoundTrip(t *testing.T) {
|
||||
handler := startTestHandler(t)
|
||||
handler := newTestHandler(t, Policy{})
|
||||
content := []byte("the cached archive")
|
||||
|
||||
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
|
||||
// blocks that arrive out of order must still be assembled the way the client asked.
|
||||
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"})
|
||||
uploadURL, _ := created["signed_upload_url"].(string)
|
||||
@@ -151,6 +142,17 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||
}
|
||||
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()),
|
||||
"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{
|
||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||
@@ -163,7 +165,7 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
handler := startTestHandler(t)
|
||||
handler := newTestHandler(t, Policy{})
|
||||
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
|
||||
require.Equal(t, true, saved["ok"])
|
||||
|
||||
@@ -198,6 +200,18 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
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) {
|
||||
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||
"key": "never-reserved", "version": "v1", "size_bytes": 1,
|
||||
@@ -225,7 +239,7 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
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.NotEmpty(t, got["archiveLocation"])
|
||||
})
|
||||
@@ -239,3 +253,101 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
||||
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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ID uint64 `json:"id" boltholdKey:"ID"`
|
||||
Repo string `json:"repo" boltholdIndex:"Repo"`
|
||||
Key string `json:"key" boltholdIndex:"Key"`
|
||||
Version string `json:"version" boltholdIndex:"Version"`
|
||||
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"`
|
||||
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
||||
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
||||
|
||||
@@ -5,6 +5,8 @@ package artifactcache
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -15,23 +17,38 @@ import (
|
||||
// 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
|
||||
// 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/"
|
||||
|
||||
// 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.
|
||||
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
cred, ok := h.lookupCredential(bearerToken(r))
|
||||
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||
if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||
http.NotFound(w, r)
|
||||
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, "/"))
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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.
|
||||
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)
|
||||
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 {
|
||||
@@ -55,5 +72,24 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
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
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json/v2"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -14,8 +16,6 @@ import (
|
||||
"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) {
|
||||
var gotHost, gotPath, gotProto string
|
||||
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}`)
|
||||
}))
|
||||
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)
|
||||
defer handler.Close()
|
||||
const token = "forward-token"
|
||||
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
|
||||
|
||||
client := &http.Client{Transport: &bearerTransport{token: token}}
|
||||
post := func(path string) int {
|
||||
noRedirect := func(token string) *http.Client {
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
resp, err := client.Do(req)
|
||||
resp, err := noRedirect(token).Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
defer resp.Body.Close()
|
||||
body := map[string]string{}
|
||||
_ = json.UnmarshalRead(resp.Body, &body)
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
||||
"an unregistered token is forwarded nowhere")
|
||||
status, body := post(rpc)
|
||||
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"))
|
||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||
status, _ = post(rpc)
|
||||
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.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
||||
assert.Equal(t, rpc, gotPath)
|
||||
|
||||
gotPath = ""
|
||||
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do"))
|
||||
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo"))
|
||||
status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"io"
|
||||
"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
|
||||
// never does, so no rename can collide with a block not yet moved.
|
||||
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 {
|
||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return os.WriteFile(list, wanted, 0o600)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Storage) Remove(id uint64) {
|
||||
_ = os.Remove(s.filename(id))
|
||||
_ = os.RemoveAll(s.tempDir(id))
|
||||
// Remove deletes an entry's blob and any staged parts. It reports failure so the caller can
|
||||
// keep the entry and retry, rather than dropping the only reference to bytes on disk.
|
||||
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 {
|
||||
|
||||
+39
-110
@@ -6,11 +6,12 @@ package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -50,65 +51,29 @@ type ResponseMessage struct {
|
||||
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__"
|
||||
|
||||
func safeResolve(baseDir, relPath string) string {
|
||||
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) {
|
||||
runID := params.ByName("runId")
|
||||
|
||||
json, err := json.Marshal(FileContainerResourceURL{
|
||||
writeJSON(w, FileContainerResourceURL{
|
||||
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) {
|
||||
@@ -122,67 +87,47 @@ func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
||||
safeRunPath := safeResolve(baseDir, runID)
|
||||
safePath := safeResolve(safeRunPath, itemPath)
|
||||
|
||||
file, err := func() (WritableFile, error) {
|
||||
contentRange := req.Header.Get("Content-Range")
|
||||
if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
|
||||
return fsys.OpenAppendable(safePath)
|
||||
}
|
||||
return fsys.OpenWritable(safePath)
|
||||
}()
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
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 {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer, ok := file.(io.Writer)
|
||||
if !ok {
|
||||
panic(errors.New("File is not writable"))
|
||||
}
|
||||
|
||||
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 {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
json, err := json.Marshal(ResponseMessage{
|
||||
writeJSON(w, ResponseMessage{
|
||||
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) {
|
||||
json, err := json.Marshal(ResponseMessage{
|
||||
writeJSON(w, ResponseMessage{
|
||||
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) {
|
||||
runID := params.ByName("runId")
|
||||
|
||||
safePath := safeResolve(baseDir, runID)
|
||||
|
||||
entries, err := fs.ReadDir(fsys, safePath)
|
||||
entries, err := os.ReadDir(safePath)
|
||||
if err != nil {
|
||||
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),
|
||||
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) {
|
||||
@@ -215,7 +152,7 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
||||
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
||||
|
||||
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() {
|
||||
rel, err := filepath.Rel(safePath, path)
|
||||
if err != nil {
|
||||
@@ -241,17 +178,9 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
json, err := json.Marshal(ContainerItemResponse{
|
||||
writeJSON(w, ContainerItemResponse{
|
||||
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) {
|
||||
@@ -259,15 +188,16 @@ func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
||||
|
||||
safePath := safeResolve(baseDir, path)
|
||||
|
||||
file, err := fsys.Open(safePath)
|
||||
file, err := os.Open(safePath)
|
||||
if err != nil {
|
||||
// try gzip file
|
||||
file, err = fsys.Open(safePath + gzipExtension)
|
||||
file, err = os.Open(safePath + gzipExtension)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
w.Header().Add("Content-Encoding", "gzip")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(w, file)
|
||||
if err != nil {
|
||||
@@ -287,19 +217,18 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
||||
router := httprouter.New()
|
||||
|
||||
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
||||
fsys := readWriteFSImpl{}
|
||||
uploads(router, artifactPath, fsys)
|
||||
downloads(router, artifactPath, fsys)
|
||||
uploads(router, artifactPath)
|
||||
downloads(router, artifactPath)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", addr, port),
|
||||
Addr: net.JoinHostPort(addr, port),
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
// run server
|
||||
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 {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
|
||||
+22
-314
@@ -7,8 +7,7 @@ package artifacts
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"encoding/json/v2"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
@@ -18,238 +17,18 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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) {
|
||||
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()
|
||||
fsys := readWriteFSImpl{}
|
||||
uploads(router, artifactPath, fsys)
|
||||
downloads(router, artifactPath, fsys)
|
||||
uploads(router, artifactPath)
|
||||
downloads(router, artifactPath)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
@@ -257,8 +36,6 @@ func TestArtifactFlow(t *testing.T) {
|
||||
client := server.Client()
|
||||
client.Timeout = 5 * time.Second
|
||||
|
||||
// request performs one HTTP call and returns the status and body. The default transport adds
|
||||
// Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain.
|
||||
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, rawURL, body)
|
||||
@@ -289,6 +66,8 @@ func TestArtifactFlow(t *testing.T) {
|
||||
|
||||
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||
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)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
@@ -314,6 +93,21 @@ func TestArtifactFlow(t *testing.T) {
|
||||
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) {
|
||||
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) {
|
||||
assert := assert.New(t)
|
||||
|
||||
func TestSafeResolve(t *testing.T) {
|
||||
baseDir := "/foo/bar"
|
||||
|
||||
tests := map[string]struct {
|
||||
@@ -385,97 +177,13 @@ func TestMkdirFsImplSafeResolve(t *testing.T) {
|
||||
|
||||
for name, tc := range tests {
|
||||
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) {
|
||||
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
|
||||
require.NotNil(t, 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
|
||||
}
|
||||
|
||||
// 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
|
||||
func NewErrorExecutor(err error) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
@@ -187,15 +171,8 @@ func (e Executor) Finally(finally Executor) Executor {
|
||||
err := e(ctx)
|
||||
err2 := finally(ctx)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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
|
||||
// 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.
|
||||
@@ -223,10 +186,3 @@ func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
+121
-121
@@ -36,7 +36,6 @@ var (
|
||||
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||
|
||||
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.
|
||||
@@ -187,19 +186,16 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
defer goGitMu.Unlock()
|
||||
if remoteName == "" {
|
||||
remoteName = "origin"
|
||||
}
|
||||
|
||||
url, err := findGitRemoteURL(ctx, file, remoteName)
|
||||
url, err := findGitRemoteURL(ctx, file, "origin")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, slug, err := findGitSlug(url, githubInstance)
|
||||
return slug, err
|
||||
_, slug := findGitSlug(url, githubInstance)
|
||||
return slug, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return "CodeCommit", matches[2], nil
|
||||
return "CodeCommit", matches[2]
|
||||
} 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 {
|
||||
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 {
|
||||
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" {
|
||||
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
||||
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
|
||||
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 {
|
||||
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
|
||||
@@ -278,11 +274,12 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
||||
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)
|
||||
} 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)
|
||||
} 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
@@ -366,26 +346,35 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
|
||||
isOfflineMode := input.OfflineMode
|
||||
|
||||
// fetch latest changes
|
||||
fetchOptions, pullOptions := gitOptions(input.Token)
|
||||
|
||||
if input.InsecureSkipTLS { // For Gitea
|
||||
fetchOptions.InsecureSkipTLS = true
|
||||
pullOptions.InsecureSkipTLS = true
|
||||
fetchOptions := git.FetchOptions{
|
||||
RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD", "refs/heads/*:refs/remotes/origin/*"},
|
||||
Force: true,
|
||||
InsecureSkipTLS: input.InsecureSkipTLS,
|
||||
}
|
||||
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
|
||||
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||
shallow := isShallow(r)
|
||||
if shallow {
|
||||
if isShallow(r) {
|
||||
fetchOptions.Depth = 1
|
||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||
}
|
||||
}
|
||||
|
||||
if !isOfflineMode {
|
||||
err = r.Fetch(&fetchOptions)
|
||||
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
||||
// TODO: revalidate a mutable ref with a conditional archive request instead, once every
|
||||
// supported Gitea sends an ETag for them: https://github.com/go-gitea/gitea/pull/39289
|
||||
_, present := refRevision(r, input.Ref)
|
||||
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
|
||||
|
||||
if refresh {
|
||||
err = r.FetchContext(ctx, &fetchOptions)
|
||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
rev, _ = refRevision(r, input.Ref)
|
||||
|
||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
@@ -432,44 +405,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
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 := ""
|
||||
|
||||
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:
|
||||
if isOfflineMode && reused {
|
||||
reusedMsg = " (reused in offline mode)"
|
||||
}
|
||||
|
||||
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{
|
||||
Hash: *hash,
|
||||
Force: true,
|
||||
@@ -478,40 +420,43 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// cloneAtDepth clones input.URL into input.Dir using opts.
|
||||
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
||||
// cloneAtDepth falls back to a full clone when the shallow attempt fails.
|
||||
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||
if input.Depth > 0 {
|
||||
for _, refName := range []plumbing.ReferenceName{
|
||||
plumbing.NewBranchReferenceName(input.Ref),
|
||||
plumbing.NewTagReferenceName(input.Ref),
|
||||
} {
|
||||
shallowOpts := opts
|
||||
shallowOpts.Depth = input.Depth
|
||||
shallowOpts.SingleBranch = true
|
||||
shallowOpts.ReferenceName = refName
|
||||
shallowOpts.Tags = git.NoTags
|
||||
|
||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||
if plumbing.IsHash(input.Ref) {
|
||||
r, err := fetchPinnedSHA(ctx, input, opts)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
||||
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
||||
logger.Debugf("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.Depth = input.Depth
|
||||
shallowOpts.SingleBranch = true
|
||||
shallowOpts.ReferenceName = refName
|
||||
shallowOpts.Tags = git.NoTags
|
||||
|
||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||
if err := removePartialClone(input.Dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||
@@ -520,14 +465,65 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
|
||||
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.
|
||||
func isShallow(r *git.Repository) bool {
|
||||
shallows, err := r.Storer.Shallow()
|
||||
return err == nil && len(shallows) > 0
|
||||
}
|
||||
|
||||
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
||||
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
||||
// shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
|
||||
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||
tagRef := plumbing.NewTagReferenceName(ref)
|
||||
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)
|
||||
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
|
||||
}
|
||||
+188
-42
@@ -7,17 +7,26 @@ package git
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -45,9 +54,7 @@ func TestFindGitSlug(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range slugTests {
|
||||
provider, slug, err := findGitSlug(tt.url, "github.com")
|
||||
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
provider, slug := findGitSlug(tt.url, "github.com")
|
||||
assert.Equal(tt.provider, provider)
|
||||
assert.Equal(tt.slug, slug)
|
||||
}
|
||||
@@ -81,45 +88,20 @@ func cleanGitHooks(dir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFindGitRemoteURL(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
basedir := t.TempDir()
|
||||
err := gitCmd("init", basedir)
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
err = cleanGitHooks(basedir)
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
remoteURL := "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name"
|
||||
err = gitCmd("-C", basedir, "remote", "add", "origin", remoteURL)
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
u, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(remoteURL, u)
|
||||
|
||||
remoteURL = "[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) {
|
||||
func TestFindGithubRepoUsesOrigin(t *testing.T) {
|
||||
basedir := t.TempDir()
|
||||
const remoteURL = "https://github.com/owner/repo.git"
|
||||
require.NoError(t, gitCmd("init", 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", "ghe", "[email protected]:team/project.git"))
|
||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
||||
|
||||
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.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) {
|
||||
@@ -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, "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", "origin", "v1"))
|
||||
|
||||
@@ -482,14 +464,43 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
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()
|
||||
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()))
|
||||
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
||||
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||
assert.NoFileExists(t, shallowMarker(dir))
|
||||
assert.Equal(t, unadvertisedSHA, 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) {
|
||||
@@ -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 {
|
||||
t.Helper()
|
||||
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')
|
||||
w, _ := lw.buffer.WriteString(line)
|
||||
written += w
|
||||
if err == nil {
|
||||
lw.handleLine(lw.buffer.String())
|
||||
lw.buffer.Reset()
|
||||
} else if err == io.EOF {
|
||||
break
|
||||
} else {
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
lw.handleLine(lw.buffer.String())
|
||||
lw.buffer.Reset()
|
||||
}
|
||||
|
||||
return written, nil
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
@@ -25,26 +29,27 @@ func (e ExitCodeError) Error() string {
|
||||
|
||||
// NewContainerInput the input for the New function
|
||||
type NewContainerInput struct {
|
||||
Image string
|
||||
Username string
|
||||
Password string
|
||||
Entrypoint []string
|
||||
Cmd []string
|
||||
WorkingDir string
|
||||
Env []string
|
||||
Binds []string
|
||||
Mounts map[string]string
|
||||
Name string
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
NetworkMode string
|
||||
Privileged bool
|
||||
UsernsMode string
|
||||
Platform string
|
||||
Options string
|
||||
NetworkAliases []string
|
||||
ExposedPorts nat.PortSet
|
||||
PortBindings nat.PortMap
|
||||
Image string
|
||||
Username string
|
||||
Password string
|
||||
Entrypoint []string
|
||||
Cmd []string
|
||||
WorkingDir string
|
||||
Env []string
|
||||
Binds []string
|
||||
Mounts map[string]string
|
||||
Name string
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
NetworkMode string
|
||||
Privileged bool
|
||||
UsernsMode string
|
||||
Platform string
|
||||
RunnerOptions string // container options the runner was configured with, trusted
|
||||
WorkflowOptions string // container options the workflow asked for, untrusted
|
||||
NetworkAliases []string
|
||||
ExposedPorts nat.PortSet
|
||||
PortBindings nat.PortMap
|
||||
|
||||
// Gitea specific
|
||||
AutoRemove bool
|
||||
@@ -74,6 +79,24 @@ const (
|
||||
// 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
|
||||
@@ -83,15 +106,24 @@ type Info struct {
|
||||
// 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
|
||||
type Container interface {
|
||||
Create(capAdd, capDrop []string) common.Executor
|
||||
ConnectToNetwork(name string) common.Executor
|
||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||
CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor
|
||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||
Inspect(ctx context.Context) (*Info, error)
|
||||
DumpLogs(ctx context.Context) error
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
//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
|
||||
// * 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
|
||||
//
|
||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||
@@ -17,8 +18,7 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -153,6 +153,7 @@ type containerOptions struct {
|
||||
runtime string
|
||||
autoRemove bool
|
||||
init bool
|
||||
umask opts.UmaskOpt
|
||||
annotations *opts.MapOpts
|
||||
|
||||
Image string
|
||||
@@ -327,6 +328,9 @@ func addFlags(flags *pflag.FlagSet) *containerOptions {
|
||||
flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm")
|
||||
flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use")
|
||||
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.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,
|
||||
// a HostConfig and returns them with the specified command.
|
||||
// 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 (
|
||||
attachStdin = copts.attach.Get("stdin")
|
||||
attachStdout = copts.attach.Get("stdout")
|
||||
@@ -722,6 +726,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
||||
MaskedPaths: maskedPaths,
|
||||
ReadonlyPaths: readonlyPaths,
|
||||
Annotations: copts.annotations.GetAll(),
|
||||
Umask: copts.umask.Value(),
|
||||
}
|
||||
|
||||
if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() {
|
||||
@@ -959,11 +964,11 @@ func parseSecurityOpts(securityOpts []string) ([]string, error) {
|
||||
if err != nil {
|
||||
return securityOpts, fmt.Errorf("opening seccomp profile (%s) failed: %w", v, err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := json.Compact(&b, f); err != nil {
|
||||
profile := jsontext.Value(f)
|
||||
if err := profile.Compact(); err != nil {
|
||||
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.
|
||||
// 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
|
||||
// * 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) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -51,15 +53,16 @@ func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *
|
||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
copts := addFlags(flags)
|
||||
copts.env = opts.NewListOpts(validateEnv) // addFlags registered this field's address, so the swap takes effect
|
||||
cf := registerCreateFlags(flags)
|
||||
|
||||
args, err := shellquote.Split(options)
|
||||
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 {
|
||||
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
|
||||
@@ -73,6 +76,30 @@ func createFlagsFromOptions(options string) *createFlags {
|
||||
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 {
|
||||
if !slices.Contains(pullPolicies, cf.pull) {
|
||||
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) {
|
||||
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)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "linux/arm64", input.Platform)
|
||||
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||
|
||||
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
|
||||
kept := &NewContainerInput{Platform: "linux/amd64", RunnerOptions: "--privileged"}
|
||||
NewContainer(kept)
|
||||
assert.Equal(t, "linux/amd64", kept.Platform)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
@@ -20,8 +20,8 @@ type dockerMessage struct {
|
||||
Stream string `json:"stream"`
|
||||
Error string `json:"error"`
|
||||
ErrorDetail struct {
|
||||
Message string
|
||||
}
|
||||
Message string `json:"message"`
|
||||
} `json:"errorDetail"`
|
||||
Status string `json:"status"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
@@ -60,15 +60,16 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
||||
return errors.New(msg.ErrorDetail.Message)
|
||||
}
|
||||
|
||||
if msg.Status != "" {
|
||||
switch {
|
||||
case msg.Status != "":
|
||||
if msg.Progress != "" {
|
||||
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
||||
} else {
|
||||
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
|
||||
}
|
||||
} else if msg.Stream != "" {
|
||||
case msg.Stream != "":
|
||||
writeLog(logger, isError, "%s", msg.Stream)
|
||||
} else {
|
||||
default:
|
||||
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
"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
|
||||
// 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.
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||
}
|
||||
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{
|
||||
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||
})
|
||||
@@ -51,6 +54,7 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
|
||||
}
|
||||
|
||||
var errs []error
|
||||
networks:
|
||||
for _, n := range networks.Items {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
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
|
||||
// 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
|
||||
}
|
||||
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 {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
@@ -172,3 +229,49 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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) {
|
||||
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.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
|
||||
// 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 (m *mockDockerClient) NetworkConnect(ctx context.Context, id string, opts mobyclient.NetworkConnectOptions) (mobyclient.NetworkConnectResult, error) {
|
||||
return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||
}
|
||||
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
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{
|
||||
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
||||
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||
{Network: network.Network{ID: "orphan"}},
|
||||
{Network: network.Network{ID: "busy"}},
|
||||
{Network: network.Network{ID: "starting"}},
|
||||
{ID: "orphan"},
|
||||
{ID: "busy"},
|
||||
{ID: "starting"},
|
||||
{ID: "cached"},
|
||||
{ID: "shared"},
|
||||
}}, nil)
|
||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||
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{}).
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
+179
-231
File diff suppressed because it is too large.
Load diff
+228
-239
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,10 @@ import (
|
||||
"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
|
||||
// requested name, tag and architecture exists in the local docker image store
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
@@ -74,6 +91,30 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
func NewDockerNetworkConnectExecutor(_, _ string) common.Executor {
|
||||
return func(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerNetworkDisconnectExecutor(_, _ string) common.Executor {
|
||||
return func(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveOrphanNetworks(_ context.Context, _, _ string, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func SanitizeBinds(_ context.Context, _, binds []string) []string {
|
||||
return binds
|
||||
}
|
||||
|
||||
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
return nil
|
||||
}
|
||||
@@ -8,40 +8,67 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, vol := range list.Items {
|
||||
if vol.Name == volumeName {
|
||||
return removeExecutor(volumeName, force)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Volume not found - do nothing
|
||||
return nil
|
||||
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
for _, volumeName := range volumeNames {
|
||||
if _, err := cli.VolumeCreate(ctx, client.VolumeCreateOptions{
|
||||
Name: volumeName,
|
||||
Labels: runnerLabels(runnerUUID),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeExecutor(volume string, force bool) common.Executor {
|
||||
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{
|
||||
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID).Add("dangling", "true"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, item := range volumes.Items {
|
||||
created, err := time.Parse(time.RFC3339, item.CreatedAt)
|
||||
// an unreadable or recent timestamp may belong to a job that is still starting up
|
||||
if err != nil || created.After(createdBefore) || item.Labels[runnerUUIDLabel] != runnerUUID {
|
||||
continue
|
||||
}
|
||||
if _, err := cli.VolumeRemove(ctx, item.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", item.Name, err))
|
||||
continue
|
||||
}
|
||||
common.Logger(ctx).Infof("removed docker volume %s left behind by an earlier job", item.Name)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("docker volume rm %s", volume)
|
||||
common.Logger(ctx).Debugf("docker volume rm %s", volumeName)
|
||||
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
@@ -53,7 +80,10 @@ func removeExecutor(volume string, force bool) common.Executor {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"gitea.com/gitea/runner/act/lookpath"
|
||||
"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/osfs"
|
||||
"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 {
|
||||
return func(ctx context.Context) error {
|
||||
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 {
|
||||
if err := os.RemoveAll(destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(tarStream)
|
||||
cp := &filecollector.CopyCollector{
|
||||
DstDir: destPath,
|
||||
}
|
||||
for {
|
||||
ti, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if ti.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return errors.New("CopyTarStream has been cancelled")
|
||||
}
|
||||
if err := cp.WriteFile(ti.Name, ti.FileInfo(), ti.Linkname, tr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
srcPrefix := filepath.Dir(srcPath)
|
||||
@@ -142,10 +110,10 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
|
||||
ignorer = gitignore.NewMatcher(ps)
|
||||
}
|
||||
fc := &filecollector.FileCollector{
|
||||
Fs: &filecollector.DefaultFs{},
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
Handler: &filecollector.CopyCollector{
|
||||
DstDir: destPath,
|
||||
},
|
||||
@@ -180,7 +148,6 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
|
||||
srcPrefix += string(filepath.Separator)
|
||||
}
|
||||
fc := &filecollector.FileCollector{
|
||||
Fs: &filecollector.DefaultFs{},
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: tc,
|
||||
@@ -246,24 +213,8 @@ func (w *ptyWriter) Write(buf []byte) (int, error) {
|
||||
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) {
|
||||
f, err := lookpath.LookPath2(cmd, &localEnv{env: env})
|
||||
f, err := lookpath.LookPath2(cmd, env)
|
||||
if err != nil {
|
||||
err := "Cannot find: " + cmd + " in PATH"
|
||||
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
|
||||
@@ -275,7 +226,7 @@ func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string
|
||||
}
|
||||
|
||||
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||
ppty, tty, err := openPty()
|
||||
ppty, tty, err := pty.Open()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -401,8 +352,7 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
}
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||
return ExitCodeError(exitErr.ExitCode())
|
||||
}
|
||||
return err
|
||||
@@ -680,8 +630,11 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string {
|
||||
func goArchToActionArch(arch string) string {
|
||||
archMapper := map[string]string{
|
||||
"x86_64": "X64",
|
||||
"amd64": "X64",
|
||||
"386": "X86",
|
||||
"arm": "ARM",
|
||||
"aarch64": "ARM64",
|
||||
"arm64": "ARM64",
|
||||
}
|
||||
if arch, ok := archMapper[arch]; ok {
|
||||
return arch
|
||||
@@ -691,7 +644,9 @@ func goArchToActionArch(arch string) string {
|
||||
|
||||
func goOsToActionOs(os string) string {
|
||||
osMapper := map[string]string{
|
||||
"darwin": "macOS",
|
||||
"linux": "Linux",
|
||||
"darwin": "macOS",
|
||||
"windows": "Windows",
|
||||
}
|
||||
if os, ok := osMapper[os]; ok {
|
||||
return os
|
||||
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
@@ -42,10 +47,32 @@ func TestCopyDir(t *testing.T) {
|
||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||
_ = os.MkdirAll(e.ToolCache, 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -46,10 +46,11 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
||||
}
|
||||
singleLineEnv := strings.Index(line, "=")
|
||||
multiLineEnv := strings.Index(line, "<<")
|
||||
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
|
||||
switch {
|
||||
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
|
||||
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
||||
} else if multiLineEnv != -1 {
|
||||
multiLineEnvContent := ""
|
||||
case multiLineEnv != -1:
|
||||
var multiLineEnvContent []string
|
||||
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
||||
delimiterFound := false
|
||||
for s.Scan() {
|
||||
@@ -58,10 +59,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
||||
delimiterFound = true
|
||||
break
|
||||
}
|
||||
if multiLineEnvContent != "" {
|
||||
multiLineEnvContent += "\n"
|
||||
}
|
||||
multiLineEnvContent += content
|
||||
multiLineEnvContent = append(multiLineEnvContent, content)
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return fmt.Errorf("reading env file: %w", err)
|
||||
@@ -69,8 +67,8 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
||||
if !delimiterFound {
|
||||
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
||||
}
|
||||
localEnv[line[:multiLineEnv]] = multiLineEnvContent
|
||||
} else {
|
||||
localEnv[line[:multiLineEnv]] = strings.Join(multiLineEnvContent, "\n")
|
||||
default:
|
||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 50 of 245 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user