mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-15 12:23:42 +00:00
Compare commits
47
Commits
e3607b576e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f59e3e418 | ||
|
|
c2046d597c | ||
|
|
645b17de72 | ||
|
|
0dfec377d2 | ||
|
|
b5305c6785 | ||
|
|
a021d3139d | ||
|
|
84c0fcd1fa | ||
|
|
c59caa9689 | ||
|
|
85d007c47f | ||
|
|
d34e4cf11a | ||
|
|
8e999cd039 | ||
|
|
cd748053b0 | ||
|
|
9bb72bbe5c | ||
|
|
79faf5e55d | ||
|
|
993389d428 | ||
|
|
db76fd5159 | ||
|
|
c8b16f4792 | ||
|
|
02446620df | ||
|
|
8c8950d9ef | ||
|
|
b88d111cc5 | ||
|
|
bb6d793cb9 | ||
|
|
95ef17d022 | ||
|
|
ee03687688 | ||
|
|
30e600d8f6 | ||
|
|
664978c819 | ||
|
|
aa56f46c89 | ||
|
|
7a9fccb842 | ||
|
|
7d6e1ff021 | ||
|
|
319567213d | ||
|
|
0093731295 | ||
|
|
3e1b4b42c2 | ||
|
|
dcba546cb3 | ||
|
|
142d9f8c88 | ||
|
|
54b2cb944c | ||
|
|
b65fd0f8f8 | ||
|
|
f2fbd67962 | ||
|
|
d6f5b68a0d | ||
|
|
6e42621b4a | ||
|
|
228effab64 | ||
|
|
a46d3f3eda | ||
|
|
2e72cf902a | ||
|
|
f4741df90d | ||
|
|
261ab374ce | ||
|
|
ca8e9c3c64 | ||
|
|
d8d4a4657d | ||
|
|
5fd2f82735 | ||
|
|
c6567296a0 |
No files matched your search
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
- run: make lint-pr-title
|
- run: make lint-pr-title
|
||||||
env:
|
env:
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
@@ -34,6 +34,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
- name: Import GPG key
|
||||||
|
id: import_gpg
|
||||||
|
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||||
|
with:
|
||||||
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
passphrase: ${{ secrets.PASSPHRASE }}
|
||||||
|
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7
|
uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7
|
||||||
with:
|
with:
|
||||||
@@ -47,6 +54,7 @@ jobs:
|
|||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
GORELEASER_FORCE_TOKEN: "gitea"
|
GORELEASER_FORCE_TOKEN: "gitea"
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||||
|
|
||||||
release-image:
|
release-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
# daemon retains them between runs, and their digest pins skip the registry when it has them.
|
# daemon retains them between runs, and their digest pins skip the registry when it has them.
|
||||||
- name: pre-pull test images
|
- name: pre-pull test images
|
||||||
env:
|
env:
|
||||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 # renovate: datasource=docker
|
TEST_JOB_IMAGE: node:26-bookworm-slim@sha256:bc0a2afd3b74ce12222fc8bab99271e66257f021078f5f4a5d687a77d280c1d9 # renovate: datasource=docker
|
||||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:a3554c0d0b48177b1413c26e01f695334428020d9df01243fbae3e11cfd17417 # renovate: datasource=docker
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:a3554c0d0b48177b1413c26e01f695334428020d9df01243fbae3e11cfd17417 # renovate: datasource=docker
|
||||||
run: |
|
run: |
|
||||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||||
@@ -57,6 +57,32 @@ jobs:
|
|||||||
make coverage-report
|
make coverage-report
|
||||||
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
windows:
|
||||||
|
name: test on windows
|
||||||
|
runs-on: ubuntu-latest-kvm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
- uses: ./.gitea/actions/go-setup
|
||||||
|
- name: prepare anonymous docker config
|
||||||
|
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
||||||
|
- name: windows vm cache key
|
||||||
|
id: windows-vm
|
||||||
|
run: echo "key=windows-vm-$(grep '^WINDOWS_TEST_IMAGE' Makefile | cat - scripts/test-windows.sh scripts/test-windows-setup.ps1 | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ~/.cache/windows-vm
|
||||||
|
key: ${{ steps.windows-vm.outputs.key }}
|
||||||
|
- name: test on windows
|
||||||
|
id: test-windows
|
||||||
|
run: WINDOWS_TEST_CACHE="$HOME/.cache/windows-vm" make test-windows
|
||||||
|
env:
|
||||||
|
WINDOWS_TEST_TIMEOUT: 45
|
||||||
|
- if: always() && steps.test-windows.outputs.save-cache == 'true'
|
||||||
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: ~/.cache/windows-vm
|
||||||
|
key: ${{ steps.windows-vm.outputs.key }}
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: gitea compatibility (${{ matrix.gitea_image }})
|
name: gitea compatibility (${{ matrix.gitea_image }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/gitea-runner
|
/gitea-runner
|
||||||
|
/gitea-runner.exe
|
||||||
.env
|
.env
|
||||||
!/act/runner/testdata/secrets/.env
|
!/act/runner/testdata/secrets/.env
|
||||||
.runner
|
.runner
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ linters:
|
|||||||
deny:
|
deny:
|
||||||
- pkg: golang.org/x/exp
|
- pkg: golang.org/x/exp
|
||||||
desc: it's experimental and unreliable
|
desc: it's experimental and unreliable
|
||||||
|
- pkg: unsafe
|
||||||
|
desc: use typed APIs instead
|
||||||
logging:
|
logging:
|
||||||
files:
|
files:
|
||||||
- $all
|
- $all
|
||||||
|
|||||||
+5
-2
@@ -34,8 +34,6 @@ builds:
|
|||||||
goarch: s390x
|
goarch: s390x
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: windows
|
|
||||||
goarch: arm64
|
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: s390x
|
goarch: s390x
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
@@ -82,6 +80,7 @@ builds:
|
|||||||
publishers:
|
publishers:
|
||||||
- name: cloudflare-r2
|
- name: cloudflare-r2
|
||||||
checksum: true
|
checksum: true
|
||||||
|
signature: true
|
||||||
extra_files:
|
extra_files:
|
||||||
- glob: ./**.xz
|
- glob: ./**.xz
|
||||||
- glob: ./**.sha256
|
- glob: ./**.sha256
|
||||||
@@ -102,6 +101,10 @@ checksum:
|
|||||||
extra_files:
|
extra_files:
|
||||||
- glob: ./**.xz
|
- glob: ./**.xz
|
||||||
|
|
||||||
|
signs:
|
||||||
|
- artifacts: checksum
|
||||||
|
args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
version_template: "{{ .Branch }}-devel"
|
version_template: "{{ .Branch }}-devel"
|
||||||
|
|
||||||
|
|||||||
+25
-14
@@ -23,11 +23,14 @@ depending on the prefix:
|
|||||||
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
|
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
|
||||||
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
|
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
|
||||||
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
|
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
|
||||||
- **Forwarding a command from step output?** Leave the payload alone, it arrived escaped and is
|
- **Forwarding a command from step output?** Preserve its escaping unless masking changes it.
|
||||||
decoded once. Decoding here double-decodes and destroys multi-line.
|
Decoding for display happens in the UI.
|
||||||
- **No prefix?** Do not escape, and split multi-line values into one row each.
|
- **No prefix?** Do not escape, and split multi-line values into one row each.
|
||||||
- **Interpolating a secret?** Masking runs after escaping, so `AppendSecretMasker` registers the
|
- **Interpolating a secret?** Masking runs after escaping. Recognised command payloads are
|
||||||
encoded forms too.
|
normalised before masking to cover partially escaped values. Their original escaping is
|
||||||
|
preserved when no replacement occurs. `AppendSecretMasker` also registers encoded forms for
|
||||||
|
other output. Complete nonblank one-character values are masked, while one-byte fragments
|
||||||
|
of multiline values are skipped.
|
||||||
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command
|
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command
|
||||||
handler folds an annotation's location into its decoded message and escapes the result again.
|
handler folds an annotation's location into its decoded message and escapes the result again.
|
||||||
|
|
||||||
@@ -48,21 +51,29 @@ fields.
|
|||||||
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
|
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
|
||||||
package functions.
|
package functions.
|
||||||
|
|
||||||
## 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
|
## End-to-end compatibility tests
|
||||||
|
|
||||||
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
`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
|
It requires Docker and is excluded from `make test`. CI runs stable and nightly variants in
|
||||||
parallel.
|
parallel. Outside a container it runs `go test` in a Linux container on its own network, as CI does,
|
||||||
|
so both take the same paths. `E2E_ARGS` passes extra flags, e.g. `make test-e2e E2E_ARGS='-run TestCompatibility/dispatch'`.
|
||||||
|
|
||||||
The `cli_*` scenarios build the binary and drive `register` and `daemon` as a user would, including `--once`,
|
The `cli_*` scenarios build the binary and drive `register` and `daemon` as a user would, including `--once`,
|
||||||
`--labels` and shutdown with a running job. `failure_paths` covers failed, skipped, tolerated and timed-out jobs.
|
`--labels` and shutdown with a running job. `failure_paths` covers failed, skipped and tolerated jobs, `job_timeout`
|
||||||
`label_routing` and `capacity` cover scheduling, `reconnect` drops the connection to Gitea through a proxy, and
|
a job killed at the runner's deadline. `label_routing` and `capacity` cover scheduling, `reconnect` drops the
|
||||||
`host_mode` and `reusable_workflow` cover host labels and `workflow_call`.
|
connection to Gitea through a proxy, and `host_mode` and `reusable_workflow` cover host labels and `workflow_call`.
|
||||||
|
`dispatch`, `failure_paths` and `reusable_workflow` run in every mode: through the daemon and `exec`, each on
|
||||||
|
Docker and Kubernetes.
|
||||||
|
|
||||||
The suite shares one Gitea and regular runner. Cache and ephemeral scenarios use isolated
|
The suite shares one Gitea, regular runner and k3s cluster. Cache and ephemeral scenarios use isolated
|
||||||
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
|
repository runners. A run path is `<workflow>@<ref>` and a log row is `<timestamp>Z <payload>`.
|
||||||
|
|
||||||
|
## Windows tests
|
||||||
|
|
||||||
|
`make test-windows` cross-compiles the test binaries, installs Windows Server 2025 Core in a
|
||||||
|
[dockur/windows](https://github.com/dockur/windows) QEMU VM on the docker host and runs them there.
|
||||||
|
The host must be amd64 and allow privileged containers. `WINDOWS_TEST_TIMEOUT` sets the minutes
|
||||||
|
allowed for installing Windows and running the tests, 165 by default. The VM uses KVM when the host
|
||||||
|
has `/dev/kvm`, otherwise Windows is emulated and much slower. Docker-backed tests skip in the VM
|
||||||
|
and the race detector is off, it needs cgo. `WINDOWS_TEST_CACHE` names a directory to keep the
|
||||||
|
installed Windows in, so later runs skip the install. CI runs this on every pull request.
|
||||||
+7
-5
@@ -19,14 +19,15 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.9.1-dind AS dind
|
FROM docker:29.9.3-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
RUN apk add --no-cache s6 bash git tzdata nftables
|
# upgrade first: the docker base image can lag behind Alpine security fixes
|
||||||
|
RUN apk upgrade --no-cache && apk add --no-cache s6 bash git tzdata nftables
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
@@ -41,7 +42,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.9.1-dind-rootless AS dind-rootless
|
FROM docker:29.9.3-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
|||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk add --no-cache s6 bash git tzdata nftables
|
# upgrade first: the docker base image can lag behind Alpine security fixes
|
||||||
|
RUN apk upgrade --no-cache && apk add --no-cache s6 bash git tzdata nftables
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
@@ -75,7 +77,7 @@ ARG VERSION=dev
|
|||||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
RUN apk add --no-cache tini bash git tzdata
|
RUN apk upgrade --no-cache && apk add --no-cache tini bash git tzdata
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
DIST := dist
|
DIST := dist
|
||||||
EXECUTABLE := gitea-runner
|
|
||||||
GO ?= go
|
GO ?= go
|
||||||
|
EXECUTABLE := gitea-runner$(shell $(GO) env GOEXE)
|
||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||||
|
|
||||||
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
|
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
|
||||||
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:8856b200466e9a5af9cc0d4de4ca96f73547833bcdddf9a567189ff772c4d372 # renovate: datasource=docker
|
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:b96ba465021b1a4c6a1c9e79220c2b9bfad0f29b09dbb528bbfb35411443a3a4 # renovate: datasource=docker
|
||||||
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker
|
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.2-k3s1@sha256:21ea6e0fac4e3930c2ce8aa40ddd41a8b18cb3e9a0222c030dc005e7e88c4332 # renovate: datasource=docker
|
||||||
|
WINDOWS_TEST_IMAGE ?= dockurr/windows:6.06@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea # renovate: datasource=docker
|
||||||
|
|
||||||
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
||||||
|
|
||||||
@@ -150,11 +151,15 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
|||||||
test-dind: ## run the daemon-facing tests against the built dind image or podman (TARGET=dind|dind-rootless|podman)
|
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)
|
@PODMAN_TEST_IMAGE=$(PODMAN_TEST_IMAGE) ./scripts/test-dind.sh $(TARGET)
|
||||||
|
|
||||||
|
.PHONY: test-windows
|
||||||
|
test-windows: ## run the tests on Windows Server Core in a QEMU VM on the docker host
|
||||||
|
@GO=$(GO) WINDOWS_TEST_IMAGE=$(WINDOWS_TEST_IMAGE) ./scripts/test-windows.sh
|
||||||
|
|
||||||
E2E_CONCURRENCY ?= 8
|
E2E_CONCURRENCY ?= 8
|
||||||
|
|
||||||
.PHONY: test-e2e
|
.PHONY: test-e2e
|
||||||
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
||||||
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_K3S_IMAGE=$(E2E_K3S_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh
|
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_K3S_IMAGE=$(E2E_K3S_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh $(E2E_ARGS)
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: $(GOFILES) ## install the runner binary via `go install`
|
install: $(GOFILES) ## install the runner binary via `go install`
|
||||||
|
|||||||
@@ -6,7 +6,5 @@
|
|||||||
//
|
//
|
||||||
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
|
// Inspired by https://github.com/sp-ricard-valverde/github-act-cache-server
|
||||||
//
|
//
|
||||||
// TODO: Authorization
|
|
||||||
// TODO: Restrictions for accessing a cache, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache
|
|
||||||
// TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
// TODO: Force deleting cache entries, see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||||
package artifactcache
|
package artifactcache
|
||||||
+153
-79
@@ -59,6 +59,11 @@ type credKey struct{}
|
|||||||
type JobCredential struct {
|
type JobCredential struct {
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
|
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
BaseRef string `json:"base_ref"`
|
||||||
|
DefaultRef string `json:"default_ref"`
|
||||||
|
CacheMode string `json:"cache_mode"` // empty allows everything
|
||||||
|
|
||||||
// Results is the instance whose artifact service this server forwards for the job, and
|
// Results is the instance whose artifact service this server forwards for the job, and
|
||||||
// InsecureTLS how the runner reaches it; see results.go.
|
// InsecureTLS how the runner reaches it; see results.go.
|
||||||
Results string `json:"-"`
|
Results string `json:"-"`
|
||||||
@@ -68,6 +73,10 @@ type JobCredential struct {
|
|||||||
PublicURL string `json:"public_url"`
|
PublicURL string `json:"public_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cred JobCredential) owns(cache *Cache) bool {
|
||||||
|
return cache.Repo == cred.Repo && cache.Ref == cred.Ref
|
||||||
|
}
|
||||||
|
|
||||||
// credEntry holds a registered job's credential along with an active
|
// credEntry holds a registered job's credential along with an active
|
||||||
// registration count. RegisterJob is reference-counted so that if two tasks
|
// registration count. RegisterJob is reference-counted so that if two tasks
|
||||||
// briefly share an ACTIONS_RUNTIME_TOKEN — e.g. a runner that retries a task
|
// briefly share an ACTIONS_RUNTIME_TOKEN — e.g. a runner that retries a task
|
||||||
@@ -96,7 +105,10 @@ type Handler struct {
|
|||||||
stopSync context.CancelFunc
|
stopSync context.CancelFunc
|
||||||
syncing sync.WaitGroup
|
syncing sync.WaitGroup
|
||||||
|
|
||||||
outboundIP string
|
outboundIPMu sync.RWMutex
|
||||||
|
outboundIP string
|
||||||
|
detectOutboundIP func() net.IP // nil when the address is configured
|
||||||
|
instanceTransport http.RoundTripper
|
||||||
|
|
||||||
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
||||||
// runner can use these endpoints to pre-register per-job
|
// runner can use these endpoints to pre-register per-job
|
||||||
@@ -116,7 +128,7 @@ type Handler struct {
|
|||||||
|
|
||||||
policy Policy
|
policy Policy
|
||||||
|
|
||||||
// freeDisk is a field so tests can drive evictForFreeSpace without a full volume.
|
// freeDisk is a field so tests can drive makeRoom without a full volume.
|
||||||
freeDisk func(string) (uint64, error)
|
freeDisk func(string) (uint64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,19 +147,22 @@ type Options struct {
|
|||||||
// in-process RegisterJob method directly.
|
// in-process RegisterJob method directly.
|
||||||
InternalSecret string
|
InternalSecret string
|
||||||
|
|
||||||
Policy Policy
|
Policy Policy
|
||||||
Logger *logging.Logger
|
Logger *logging.Logger
|
||||||
|
InstanceTransport http.RoundTripper // reaches the instance artifact requests are forwarded to, nil for http.DefaultTransport
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartHandler starts an embedded cache or forwards cache requests to Upstream.
|
// StartHandler starts an embedded cache or forwards cache requests to Upstream.
|
||||||
func StartHandler(opts Options) (*Handler, error) {
|
func StartHandler(opts Options) (*Handler, error) {
|
||||||
dir, logger := opts.Dir, opts.Logger
|
dir, logger := opts.Dir, opts.Logger
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
creds: make(map[string]*credEntry),
|
creds: make(map[string]*credEntry),
|
||||||
internalSecret: opts.InternalSecret,
|
internalSecret: opts.InternalSecret,
|
||||||
policy: opts.Policy.withDefaults(),
|
policy: opts.Policy.withDefaults(),
|
||||||
freeDisk: disk.FreeBytes,
|
freeDisk: disk.FreeBytes,
|
||||||
dbLock: make(chan struct{}, 1),
|
dbLock: make(chan struct{}, 1),
|
||||||
|
outboundIP: opts.OutboundIP,
|
||||||
|
instanceTransport: opts.InstanceTransport,
|
||||||
}
|
}
|
||||||
|
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
@@ -156,12 +171,11 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
logger = logger.With("module", "artifactcache")
|
logger = logger.With("module", "artifactcache")
|
||||||
h.logger = logger
|
h.logger = logger
|
||||||
|
|
||||||
if opts.OutboundIP != "" {
|
if opts.OutboundIP == "" {
|
||||||
h.outboundIP = opts.OutboundIP
|
h.detectOutboundIP = common.GetOutboundIP
|
||||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
if h.RefreshExternalURL() == "" {
|
||||||
return nil, errors.New("unable to determine outbound IP address")
|
logger.Warn("unable to determine an outbound IP address for the cache server yet, set cache.host to configure one")
|
||||||
} else {
|
}
|
||||||
h.outboundIP = ip.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Upstream != "" {
|
if opts.Upstream != "" {
|
||||||
@@ -205,10 +219,10 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router := http.NewServeMux()
|
router := http.NewServeMux()
|
||||||
router.HandleFunc("GET "+apiPath+"/cache", h.bearerAuth(h.find))
|
router.HandleFunc("GET "+apiPath+"/cache", h.cacheModeAuth(false, h.find))
|
||||||
router.HandleFunc("POST "+apiPath+"/caches", h.bearerAuth(h.reserve))
|
router.HandleFunc("POST "+apiPath+"/caches", h.cacheModeAuth(true, h.reserve))
|
||||||
router.HandleFunc("PATCH "+apiPath+"/caches/{id}", h.bearerAuth(h.upload))
|
router.HandleFunc("PATCH "+apiPath+"/caches/{id}", h.cacheModeAuth(true, h.upload))
|
||||||
router.HandleFunc("POST "+apiPath+"/caches/{id}", h.bearerAuth(h.commit))
|
router.HandleFunc("POST "+apiPath+"/caches/{id}", h.cacheModeAuth(true, h.commit))
|
||||||
router.HandleFunc("POST "+apiPath+"/clean", h.bearerAuth(h.clean))
|
router.HandleFunc("POST "+apiPath+"/clean", h.bearerAuth(h.clean))
|
||||||
// Artifact GET is signed via query-string HMAC because @actions/cache
|
// Artifact GET is signed via query-string HMAC because @actions/cache
|
||||||
// does not attach Authorization when downloading archiveLocation.
|
// does not attach Authorization when downloading archiveLocation.
|
||||||
@@ -293,11 +307,36 @@ func (h *Handler) cacheForwarder(upstream string) (http.Handler, error) {
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExternalURL is "" while no address is known.
|
||||||
func (h *Handler) ExternalURL() string {
|
func (h *Handler) ExternalURL() string {
|
||||||
// TODO: make the external url configurable if necessary
|
if h == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
h.outboundIPMu.RLock()
|
||||||
|
defer h.outboundIPMu.RUnlock()
|
||||||
|
if h.outboundIP == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshExternalURL re-detects an unconfigured address, which can change after startup.
|
||||||
|
func (h *Handler) RefreshExternalURL() string {
|
||||||
|
if h != nil && h.detectOutboundIP != nil {
|
||||||
|
if ip := h.detectOutboundIP(); ip != nil {
|
||||||
|
h.outboundIPMu.Lock()
|
||||||
|
if address := ip.String(); address != h.outboundIP {
|
||||||
|
h.outboundIP = address
|
||||||
|
if ip.To4() == nil {
|
||||||
|
h.logger.Warnf("detected IPv6 address %s for the cache server, jobs on IPv4-only networks cannot reach it, set cache.host if they fail to", address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.outboundIPMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h.ExternalURL()
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) baseURL(cred JobCredential) string {
|
func (h *Handler) baseURL(cred JobCredential) string {
|
||||||
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
|
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
|
||||||
return base
|
return base
|
||||||
@@ -653,7 +692,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := h.lookupCache(db, cred.Repo, keys, version)
|
cache, err := h.lookupCache(db, cred, keys, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
return
|
||||||
@@ -669,26 +708,27 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupCache returns the entry to restore for these keys, or (nil, nil) when there is none:
|
// lookupCache returns the entry to restore or (nil, nil), dropping matches whose blob was pruned.
|
||||||
// either nothing matched, or the match had lost its blob to a prune, in which case the dangling
|
func (h *Handler) lookupCache(db *cacheDB, cred JobCredential, keys []string, version string) (*Cache, error) {
|
||||||
// entry is dropped on the way out.
|
for {
|
||||||
func (h *Handler) lookupCache(db *cacheDB, repo string, keys []string, version string) (*Cache, error) {
|
cache, err := findCache(db, cred, keys, version)
|
||||||
cache, err := findCache(db, repo, keys, version)
|
if err != nil || cache == nil {
|
||||||
if err != nil || cache == nil {
|
return nil, err
|
||||||
return nil, err
|
}
|
||||||
|
ok, err := h.storage.Exist(cache.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
// Handing out a download URL counts as access, or eviction could drop the entry between
|
||||||
|
// this call and the GET that follows it.
|
||||||
|
h.touch(db, cache)
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
if !h.deleteCache(db, cache) {
|
||||||
|
return nil, nil //nolint:nilnil // absence is not an error here
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ok, err := h.storage.Exist(cache.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
h.deleteCache(db, cache)
|
|
||||||
return nil, nil //nolint:nilnil // absence is not an error here
|
|
||||||
}
|
|
||||||
// Handing out a download URL counts as access, or eviction could drop the entry between
|
|
||||||
// this call and the GET that follows it.
|
|
||||||
h.touch(db, cache)
|
|
||||||
return cache, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/caches
|
// POST /_apis/artifactcache/caches
|
||||||
@@ -700,7 +740,7 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &Cache{Repo: cred.Repo, Key: api.Key, Version: api.Version, Size: api.Size}
|
cache := &Cache{Repo: cred.Repo, Ref: cred.Ref, Key: api.Key, Version: api.Version, Size: api.Size}
|
||||||
if cache.Size == 0 {
|
if cache.Size == 0 {
|
||||||
cache.Size = -1
|
cache.Size = -1
|
||||||
}
|
}
|
||||||
@@ -749,7 +789,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.Repo != cred.Repo {
|
if !cred.owns(cache) {
|
||||||
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -764,6 +804,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.makeRoom(r.ContentLength)
|
||||||
if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
|
if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
return
|
||||||
@@ -808,7 +849,7 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if cache.Repo != cred.Repo {
|
if !cred.owns(cache) {
|
||||||
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -866,11 +907,8 @@ func (h *Handler) commitCache(cache *Cache) error {
|
|||||||
if h.s3 != nil {
|
if h.s3 != nil {
|
||||||
return nil // lifecycle rules on the bucket bound its size
|
return nil // lifecycle rules on the bucket bound its size
|
||||||
}
|
}
|
||||||
// A commit is the only thing that grows the store, so the only thing that can push the
|
|
||||||
// volume under the floor.
|
|
||||||
h.evictRepo(db, cache.Repo)
|
h.evictRepo(db, cache.Repo)
|
||||||
h.evictTotal(db)
|
h.evictTotal(db)
|
||||||
h.evictForFreeSpace(db)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,6 +957,30 @@ func (h *Handler) bearerAuth(handler http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cacheModeAuth is bearerAuth refusing what the job's cache-mode forbids, in the shapes the toolkit reports as a warning.
|
||||||
|
func (h *Handler) cacheModeAuth(write bool, handler http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return h.bearerAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mode := credFromContext(r.Context()).CacheMode
|
||||||
|
access, forbidden := "read", "write-only"
|
||||||
|
if write {
|
||||||
|
access, forbidden = "write", "read"
|
||||||
|
}
|
||||||
|
if mode != "none" && mode != forbidden {
|
||||||
|
handler(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message := fmt.Sprintf("cache %s denied: the job's cache-mode is %s", access, mode)
|
||||||
|
switch {
|
||||||
|
case !strings.HasPrefix(r.URL.Path, cacheServiceV2Path):
|
||||||
|
h.responseJSON(w, r, http.StatusForbidden, map[string]any{"message": message})
|
||||||
|
case write:
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false, "message": message})
|
||||||
|
default:
|
||||||
|
h.twirpError(w, r, twirpDenied, errors.New(message))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
|
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
|
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
|
||||||
h.twirpError(w, r, twirpUnauthenticated, err)
|
h.twirpError(w, r, twirpUnauthenticated, err)
|
||||||
@@ -1071,25 +1133,35 @@ func (h *Handler) signedArtifactURL(cred JobCredential, cacheID uint64, exp time
|
|||||||
return h.signedURL(cred, apiPath+"/artifacts", "", cacheID, exp)
|
return h.signedURL(cred, apiPath+"/artifacts", "", cacheID, exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if not found, return (nil, nil) instead of an error.
|
// findCache tries every key in one ref before the next ref, and returns (nil, nil) on a miss.
|
||||||
func findCache(db *cacheDB, repo string, keys []string, version string) (*Cache, error) {
|
func findCache(db *cacheDB, cred JobCredential, keys []string, version string) (*Cache, error) {
|
||||||
for _, prefix := range keys {
|
refs := []string{cred.Ref}
|
||||||
// if a key in the list matches exactly, don't return partial matches
|
for _, ref := range []string{cred.BaseRef, cred.DefaultRef} {
|
||||||
exact, err := findExactCache(db, repo, prefix, version, true)
|
if ref != "" && !slices.Contains(refs, ref) {
|
||||||
if err != nil {
|
refs = append(refs, ref)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if exact != nil {
|
}
|
||||||
return exact, nil
|
caches, err := db.find(func(cache *Cache) bool {
|
||||||
}
|
return cache.Repo == cred.Repo && cache.Version == version && cache.Complete && slices.Contains(refs, cache.Ref) &&
|
||||||
caches, err := db.find(func(cache *Cache) bool {
|
slices.ContainsFunc(keys, func(prefix string) bool { return strings.HasPrefix(cache.Key, prefix) })
|
||||||
return cache.Repo == repo && strings.HasPrefix(cache.Key, prefix) && cache.Version == version && cache.Complete
|
})
|
||||||
})
|
if err != nil {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("find cache: %w", err)
|
||||||
return nil, fmt.Errorf("find cache: %w", err)
|
}
|
||||||
}
|
for _, ref := range refs {
|
||||||
if cache := latest(caches, (*Cache).createdAtNano); cache != nil {
|
for _, prefix := range keys {
|
||||||
return cache, nil
|
var exact, partial []*Cache
|
||||||
|
for _, cache := range caches {
|
||||||
|
if cache.Ref == ref && cache.Key == prefix {
|
||||||
|
exact = append(exact, cache)
|
||||||
|
} else if cache.Ref == ref && strings.HasPrefix(cache.Key, prefix) {
|
||||||
|
partial = append(partial, cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if a key in the list matches exactly, don't return partial matches
|
||||||
|
if cache := cmp.Or(latest(exact, (*Cache).createdAtNano), latest(partial, (*Cache).createdAtNano)); cache != nil {
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
|
||||||
@@ -1111,9 +1183,9 @@ func latest(caches []*Cache, stamp func(*Cache) int64) *Cache {
|
|||||||
// A completed entry is the one to restore, sorted by when it was written. An incomplete one is a
|
// A completed entry is the one to restore, sorted by when it was written. An incomplete one is a
|
||||||
// reservation being uploaded to, sorted by when it was last written to, because the upload route
|
// reservation being uploaded to, sorted by when it was last written to, because the upload route
|
||||||
// touches UsedAt on every part.
|
// touches UsedAt on every part.
|
||||||
func findExactCache(db *cacheDB, repo, key, version string, complete bool) (*Cache, error) {
|
func findExactCache(db *cacheDB, cred JobCredential, key, version string, complete bool) (*Cache, error) {
|
||||||
caches, err := db.find(func(cache *Cache) bool {
|
caches, err := db.find(func(cache *Cache) bool {
|
||||||
return cache.Key == key && cache.Repo == repo && cache.Version == version && cache.Complete == complete
|
return cred.owns(cache) && cache.Key == key && cache.Version == version && cache.Complete == complete
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("find cache: %w", err)
|
return nil, fmt.Errorf("find cache: %w", err)
|
||||||
@@ -1154,9 +1226,6 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := db.get(id)
|
cache, err := db.get(id)
|
||||||
if errors.Is(err, errCacheNotFound) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1241,23 +1310,29 @@ func (h *Handler) gcCache() {
|
|||||||
h.evictExpired(db)
|
h.evictExpired(db)
|
||||||
h.evictSuperseded(db)
|
h.evictSuperseded(db)
|
||||||
h.evictOversized(db)
|
h.evictOversized(db)
|
||||||
h.evictForFreeSpace(db)
|
_ = db.Close()
|
||||||
|
h.makeRoom(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictForFreeSpace bounds the volume itself, so it also covers bytes the cache never
|
// makeRoom evicts the least recently used entries to keep need bytes above the floor, as GitHub saves new over old.
|
||||||
// accounted for.
|
func (h *Handler) makeRoom(need int64) {
|
||||||
func (h *Handler) evictForFreeSpace(db *cacheDB) {
|
|
||||||
free, err := h.freeDisk(h.dir)
|
free, err := h.freeDisk(h.dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Debugf("free disk check: %v", err) // unsupported platform, treat as unavailable rather than full
|
h.logger.Debugf("free disk check: %v", err) // unsupported platform, treat as unavailable rather than full
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if free >= uint64(h.policy.MinFreeDisk) {
|
shortfall := h.policy.MinFreeDisk + max(need, 0) - int64(free)
|
||||||
|
if h.s3 != nil || shortfall <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
caches := h.completedByUse(db)
|
caches := h.completedByUse(db)
|
||||||
total, shortfall := totalSize(caches), h.policy.MinFreeDisk-int64(free)
|
total := totalSize(caches)
|
||||||
if total <= shortfall {
|
if total <= shortfall {
|
||||||
// Say so, or shedding everything and still being short reads as the backstop working.
|
// Say so, or shedding everything and still being short reads as the backstop working.
|
||||||
h.logger.Warnf("cache volume is %d MiB short of the free space floor with only %d MiB of cache on it; something else is filling it", shortfall/miB, total/miB)
|
h.logger.Warnf("cache volume is %d MiB short of the free space floor with only %d MiB of cache on it; something else is filling it", shortfall/miB, total/miB)
|
||||||
@@ -1280,13 +1355,12 @@ func (h *Handler) evictExpired(db *cacheDB) {
|
|||||||
h.sweep(db, func(cache *Cache) bool { return cache.UsedAt < expired })
|
h.sweep(db, func(cache *Cache) bool { return cache.UsedAt < expired })
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictSuperseded removes entries a newer one with the same key and version replaced. The
|
// evictSuperseded removes entries a newer one with the same repo, ref, key and version replaced.
|
||||||
// grouping includes Repo so two repos sharing a (key, version) do not evict each other.
|
|
||||||
func (h *Handler) evictSuperseded(db *cacheDB) {
|
func (h *Handler) evictSuperseded(db *cacheDB) {
|
||||||
type entry struct{ repo, key, version string }
|
type entry struct{ repo, ref, key, version string }
|
||||||
groups := make(map[entry][]*Cache)
|
groups := make(map[entry][]*Cache)
|
||||||
for _, cache := range h.caches(db, func(cache *Cache) bool { return cache.Complete }) {
|
for _, cache := range h.caches(db, func(cache *Cache) bool { return cache.Complete }) {
|
||||||
group := entry{cache.Repo, cache.Key, cache.Version}
|
group := entry{cache.Repo, cache.Ref, cache.Key, cache.Version}
|
||||||
groups[group] = append(groups[group], cache)
|
groups[group] = append(groups[group], cache)
|
||||||
}
|
}
|
||||||
for _, caches := range groups {
|
for _, caches := range groups {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -18,9 +19,12 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/iotest"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
@@ -49,7 +53,23 @@ func signArtifactURL(h *Handler, id int64) string {
|
|||||||
|
|
||||||
func TestHandler_ExternalURL(t *testing.T) {
|
func TestHandler_ExternalURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL())
|
assert.Empty(t, (*Handler)(nil).RefreshExternalURL())
|
||||||
|
assert.Equal(t, "http://cache.example:8080", (&Handler{outboundIP: "cache.example", port: 8080}).RefreshExternalURL())
|
||||||
|
|
||||||
|
logger, logs := logtest.NewLogger()
|
||||||
|
var ip net.IP
|
||||||
|
handler := &Handler{port: 8080, logger: logger, detectOutboundIP: func() net.IP { return ip }}
|
||||||
|
assert.Empty(t, handler.RefreshExternalURL())
|
||||||
|
ip = net.ParseIP("2001:db8::1")
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080", handler.RefreshExternalURL())
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080", handler.RefreshExternalURL())
|
||||||
|
ip = net.ParseIP("192.0.2.1")
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080", handler.ExternalURL())
|
||||||
|
assert.Equal(t, "http://192.0.2.1:8080", handler.RefreshExternalURL())
|
||||||
|
ip = nil
|
||||||
|
assert.Equal(t, "http://192.0.2.1:8080", handler.RefreshExternalURL())
|
||||||
|
require.Len(t, logs.AllRecords(), 1)
|
||||||
|
assert.Contains(t, logs.LastRecord().Message, "IPv6")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_Upstream(t *testing.T) {
|
func TestHandler_Upstream(t *testing.T) {
|
||||||
@@ -673,7 +693,7 @@ func TestHandler_evictPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_evictForFreeSpace(t *testing.T) {
|
func TestHandler_makeRoom(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
free := func(n int64) func(string) (uint64, error) {
|
free := func(n int64) func(string) (uint64, error) {
|
||||||
return func(string) (uint64, error) { return uint64(n), nil }
|
return func(string) (uint64, error) { return uint64(n), nil }
|
||||||
@@ -682,14 +702,16 @@ func TestHandler_evictForFreeSpace(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
freeDisk func(string) (uint64, error)
|
freeDisk func(string) (uint64, error)
|
||||||
|
upload int64
|
||||||
kept []string
|
kept []string
|
||||||
}{
|
}{
|
||||||
{"ample free space evicts nothing", free(defaultMinFreeDisk), []string{"oldest", "middle", "newest"}},
|
{"ample free space evicts nothing", free(defaultMinFreeDisk), 0, []string{"oldest", "middle", "newest"}},
|
||||||
{"a small shortfall sheds one entry", free(defaultMinFreeDisk - 4*miB), []string{"middle", "newest"}},
|
{"room for an upload sheds the oldest entry", free(defaultMinFreeDisk), 4 * miB, []string{"middle", "newest"}},
|
||||||
{"a shortfall the cache cannot cover sheds all of it", free(0), nil},
|
{"a shortfall the cache cannot cover sheds all of it", free(0), 0, nil},
|
||||||
{
|
{
|
||||||
"an unreadable volume is treated as unavailable, not as full",
|
"an unreadable volume is treated as unavailable, not as full",
|
||||||
func(string) (uint64, error) { return 0, errors.New("unsupported") },
|
func(string) (uint64, error) { return 0, errors.New("unsupported") },
|
||||||
|
0,
|
||||||
[]string{"oldest", "middle", "newest"},
|
[]string{"oldest", "middle", "newest"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -702,11 +724,7 @@ func TestHandler_evictForFreeSpace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
handler := newTestHandler(t, Policy{}, entries...)
|
handler := newTestHandler(t, Policy{}, entries...)
|
||||||
handler.freeDisk = tc.freeDisk
|
handler.freeDisk = tc.freeDisk
|
||||||
|
handler.makeRoom(tc.upload)
|
||||||
db, err := handler.openDB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
handler.evictForFreeSpace(db)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, tc.kept, keptKeys(t, handler, entries))
|
assert.ElementsMatch(t, tc.kept, keptKeys(t, handler, entries))
|
||||||
})
|
})
|
||||||
@@ -759,11 +777,55 @@ func TestHandler_PrefixUsesNewestReservationWithinSecond(t *testing.T) {
|
|||||||
db, err := handler.openDB()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
cache, err := findCache(db, testRepo, []string{"deps-"}, "v")
|
cache, err := findCache(db, JobCredential{Repo: testRepo}, []string{"deps-"}, "v")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, newer, cache)
|
require.Equal(t, newer, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_RestoreRefs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const main, release, pull = "refs/heads/main", "refs/heads/release", "refs/pull/1/head"
|
||||||
|
now := time.Now().Unix()
|
||||||
|
entry := func(ref, key string, age int64) *Cache {
|
||||||
|
return &Cache{Repo: testRepo, Ref: ref, Key: key, Version: "v", Complete: true, CreatedAt: now - 3600 + age, UsedAt: now}
|
||||||
|
}
|
||||||
|
exactOnMain := entry(main, "deps-exact", -1)
|
||||||
|
onRelease := entry(release, "deps-release", 0)
|
||||||
|
onMain := entry(main, "deps-main", 1)
|
||||||
|
onPull := entry(pull, "deps-pull", 2)
|
||||||
|
unscoped := entry("", "deps-unscoped", 3)
|
||||||
|
handler := newTestHandler(t, Policy{}, exactOnMain, onRelease, onMain, onPull, unscoped, entry("refs/pull/2/head", "deps-lost", 4))
|
||||||
|
for _, cache := range []*Cache{exactOnMain, onRelease, onMain, onPull, unscoped} {
|
||||||
|
writeBlob(t, handler, cache.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cred JobCredential
|
||||||
|
keys []string
|
||||||
|
want *Cache
|
||||||
|
}{
|
||||||
|
{"the default branch never restores what a pull request wrote", JobCredential{Ref: main, DefaultRef: main}, []string{"deps-"}, onMain},
|
||||||
|
{"a pull request restores its own entry first", JobCredential{Ref: pull, BaseRef: release, DefaultRef: main}, []string{"deps-"}, onPull},
|
||||||
|
{"a pull request falls back past a lost blob to its base branch before the default branch", JobCredential{Ref: "refs/pull/2/head", BaseRef: release, DefaultRef: main}, []string{"deps-"}, onRelease},
|
||||||
|
{"a branch does not restore a sibling branch", JobCredential{Ref: "refs/heads/other", DefaultRef: main}, []string{"deps-"}, onMain},
|
||||||
|
{"a prefix in the own ref wins over an exact key in the default branch", JobCredential{Ref: release, DefaultRef: main}, []string{"deps-exact", "deps-"}, onRelease},
|
||||||
|
{"a job with a ref never restores entries saved without one", JobCredential{Ref: "refs/heads/other"}, []string{"deps-"}, nil},
|
||||||
|
{"a job without a ref only restores entries saved without one", JobCredential{}, []string{"deps-"}, unscoped},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tc.cred.Repo = testRepo
|
||||||
|
cache, err := handler.lookupCache(db, tc.cred, tc.keys, "v")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.want, cache)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandler_evictOnCommit(t *testing.T) {
|
func TestHandler_evictOnCommit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
full := &Cache{Repo: testRepo, Key: "full", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
|
full := &Cache{Repo: testRepo, Key: "full", Version: "v", Complete: true, Size: 4 * miB, UsedAt: time.Now().Add(-time.Hour).Unix()}
|
||||||
@@ -808,6 +870,9 @@ func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
|
|||||||
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
handler := startTestHandler(t, Options{Policy: policy})
|
handler := startTestHandler(t, Options{Policy: policy})
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
db, err := handler.openDB()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -844,6 +909,13 @@ func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string {
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStorageDropsFailedWrites(t *testing.T) {
|
||||||
|
storage, err := NewStorage(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, storage.Write(1, 0, io.MultiReader(strings.NewReader("partial"), iotest.ErrReader(io.ErrUnexpectedEOF))))
|
||||||
|
assert.NoFileExists(t, storage.tempName(1, 0))
|
||||||
|
}
|
||||||
|
|
||||||
func writeBlob(t *testing.T, handler *Handler, id uint64) {
|
func writeBlob(t *testing.T, handler *Handler, id uint64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
require.NoError(t, handler.storage.Write(id, 0, strings.NewReader("a")))
|
require.NoError(t, handler.storage.Write(id, 0, strings.NewReader("a")))
|
||||||
@@ -890,6 +962,10 @@ func TestHandler_RejectsBearer(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandler_UnregisterRevokes(t *testing.T) {
|
func TestHandler_UnregisterRevokes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
handler, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer handler.Close()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
token string
|
token string
|
||||||
@@ -900,9 +976,6 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
|
|||||||
{"reference counted", "shared", "v", 2},
|
{"reference counted", "shared", "v", 2},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
handler, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache")})
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
revokers := make([]func(), tc.registrations)
|
revokers := make([]func(), tc.registrations)
|
||||||
for i := range revokers {
|
for i := range revokers {
|
||||||
revokers[i] = handler.RegisterJob(tc.token, JobCredential{Repo: testRepo})
|
revokers[i] = handler.RegisterJob(tc.token, JobCredential{Repo: testRepo})
|
||||||
@@ -930,7 +1003,7 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_CrossRepoIsolation(t *testing.T) {
|
func TestHandler_CrossRepoAndCacheModeIsolation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
@@ -938,6 +1011,9 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
|
|||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
|
handler.RegisterJob("token-a", JobCredential{Repo: "owner/repoA"})
|
||||||
handler.RegisterJob("token-b", JobCredential{Repo: "owner/repoB"})
|
handler.RegisterJob("token-b", JobCredential{Repo: "owner/repoB"})
|
||||||
|
handler.RegisterJob("token-a-pull", JobCredential{Repo: "owner/repoA", Ref: "refs/pull/1/head"})
|
||||||
|
handler.RegisterJob("token-a-read", JobCredential{Repo: "owner/repoA", CacheMode: "read"})
|
||||||
|
handler.RegisterJob("token-a-write-only", JobCredential{Repo: "owner/repoA", CacheMode: "write-only"})
|
||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
key := "shared-key"
|
key := "shared-key"
|
||||||
@@ -967,18 +1043,44 @@ func TestHandler_CrossRepoIsolation(t *testing.T) {
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||||
|
|
||||||
resp, err = clientA.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
clientRead := &http.Client{Transport: &bearerTransport{token: "token-a-read"}}
|
||||||
require.NoError(t, err)
|
for _, client := range []*http.Client{clientA, clientRead} {
|
||||||
resp.Body.Close()
|
resp, err = client.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader([]byte("poison")))
|
requireDenied := func(prefix string, do func() (*http.Response, error)) {
|
||||||
require.NoError(t, err)
|
resp, err := do()
|
||||||
req.Header.Set("Content-Range", "bytes 0-5/*")
|
require.NoError(t, err)
|
||||||
resp, err = clientB.Do(req)
|
defer resp.Body.Close()
|
||||||
require.NoError(t, err)
|
denied := map[string]string{}
|
||||||
resp.Body.Close()
|
require.NoError(t, json.UnmarshalRead(resp.Body, &denied))
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
assert.True(t, strings.HasPrefix(denied["message"], prefix), denied["message"])
|
||||||
|
}
|
||||||
|
requireDenied("cache write denied:", func() (*http.Response, error) {
|
||||||
|
return clientRead.Post(base+"/caches", "application/json", strings.NewReader(`{"key":"new-key","version":"`+version+`"}`))
|
||||||
|
})
|
||||||
|
requireDenied("cache read denied:", func() (*http.Response, error) {
|
||||||
|
return (&http.Client{Transport: &bearerTransport{token: "token-a-write-only"}}).Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, client := range []*http.Client{clientB, {Transport: &bearerTransport{token: "token-a-pull"}}, clientRead} {
|
||||||
|
req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader([]byte("poison")))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Content-Range", "bytes 0-5/*")
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
resp, err = client.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_ArtifactSignature(t *testing.T) {
|
func TestHandler_ArtifactSignature(t *testing.T) {
|
||||||
@@ -991,9 +1093,8 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
good := signArtifactURL(handler, 1)
|
good := signArtifactURL(handler, 1)
|
||||||
other, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache2")})
|
secret := bytes.Clone(handler.secret)
|
||||||
require.NoError(t, err)
|
secret[0] ^= 1
|
||||||
defer other.Close()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -1002,7 +1103,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
{"missing signature", fmt.Sprintf("%s/artifacts/%d", base, 1)},
|
{"missing signature", fmt.Sprintf("%s/artifacts/%d", base, 1)},
|
||||||
{"tampered signature", good[:len(good)-4] + "dead"},
|
{"tampered signature", good[:len(good)-4] + "dead"},
|
||||||
{"expired signature", handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))},
|
{"expired signature", handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))},
|
||||||
{"signature from a different server", base + strings.SplitN(signArtifactURL(other, 1), apiPath, 2)[1]},
|
{"signature from a different server", base + strings.SplitN(signArtifactURL(&Handler{secret: secret}, 1), apiPath, 2)[1]},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
resp, err := testClient.Get(tc.url)
|
resp, err := testClient.Get(tc.url)
|
||||||
@@ -1083,49 +1184,20 @@ func TestHandler_ArtifactSignatureDownload(t *testing.T) {
|
|||||||
assert.Equal(t, content, body)
|
assert.Equal(t, content, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
func TestHandler_GC_DedupPerRepoAndRef(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
handler.RegisterJob("tok-a", JobCredential{Repo: "owner/repoA"})
|
|
||||||
handler.RegisterJob("tok-b", JobCredential{Repo: "owner/repoB"})
|
|
||||||
|
|
||||||
key := "shared-dedup-key"
|
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
|
||||||
|
|
||||||
db, err := handler.openDB()
|
|
||||||
require.NoError(t, err)
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
|
stale := time.Now().Add(-inUseGrace - time.Minute).Unix()
|
||||||
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
entries := []*Cache{
|
||||||
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
{Repo: "owner/repoA", Key: "shared", Version: "v", Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1},
|
||||||
require.NoError(t, handler.insertCache(db, a))
|
{Repo: "owner/repoB", Key: "shared", Version: "v", Complete: true, CreatedAt: now, UsedAt: now, Size: 1},
|
||||||
require.NoError(t, handler.insertCache(db, b))
|
{Repo: "owner/repoA", Ref: "refs/pull/1/head", Key: "shared", Version: "v", Complete: true, CreatedAt: now, UsedAt: now, Size: 1},
|
||||||
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
|
}
|
||||||
_, err = handler.storage.Commit(a.ID, 1)
|
handler := newTestHandler(t, Policy{}, entries...)
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, handler.storage.Write(b.ID, 0, strings.NewReader("b")))
|
|
||||||
_, err = handler.storage.Commit(b.ID, 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, db.Close())
|
|
||||||
|
|
||||||
handler.gcAt = time.Time{}
|
handler.gcAt = time.Time{}
|
||||||
handler.gcCache()
|
handler.gcCache()
|
||||||
|
|
||||||
db, err = handler.openDB()
|
assert.Len(t, keptKeys(t, handler, entries), len(entries))
|
||||||
require.NoError(t, err)
|
|
||||||
defer db.Close()
|
|
||||||
after, err := db.find(func(cache *Cache) bool { return cache.Key == key && cache.Version == version })
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
repos := make(map[string]bool)
|
|
||||||
for _, c := range after {
|
|
||||||
repos[c.Repo] = true
|
|
||||||
}
|
|
||||||
assert.True(t, repos["owner/repoA"], "repoA's cache must survive dedup against repoB")
|
|
||||||
assert.True(t, repos["owner/repoB"], "repoB's cache must survive dedup against repoA")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
func TestHandler_InternalAPI_Disabled(t *testing.T) {
|
||||||
@@ -1190,10 +1262,10 @@ func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
||||||
assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo","public_url":"http://runner.example","results":"https://gitea.example","insecure_tls":true}`))
|
assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo","ref":"refs/pull/1/head","base_ref":"refs/heads/release","default_ref":"refs/heads/main","public_url":"http://runner.example","results":"https://gitea.example","insecure_tls":true}`))
|
||||||
cred, ok := handler.lookupCredential("via-internal-api")
|
cred, ok := handler.lookupCredential("via-internal-api")
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, JobCredential{Repo: testRepo, PublicURL: "http://runner.example"}, cred)
|
assert.Equal(t, JobCredential{Repo: testRepo, Ref: "refs/pull/1/head", BaseRef: "refs/heads/release", DefaultRef: "refs/heads/main", PublicURL: "http://runner.example"}, cred)
|
||||||
assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api"))
|
assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api"))
|
||||||
assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
|
assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
|
||||||
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
|
||||||
|
|||||||
@@ -40,12 +40,13 @@ const (
|
|||||||
twirpInternal = "internal"
|
twirpInternal = "internal"
|
||||||
twirpUnavailable = "unavailable"
|
twirpUnavailable = "unavailable"
|
||||||
twirpUnauthenticated = "unauthenticated"
|
twirpUnauthenticated = "unauthenticated"
|
||||||
|
twirpDenied = "permission_denied"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) registerV2Routes(router *http.ServeMux) {
|
func (h *Handler) registerV2Routes(router *http.ServeMux) {
|
||||||
router.HandleFunc("POST "+cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
router.HandleFunc("POST "+cacheServiceV2Path+"/CreateCacheEntry", h.cacheModeAuth(true, h.v2CreateCacheEntry))
|
||||||
router.HandleFunc("POST "+cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
router.HandleFunc("POST "+cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.cacheModeAuth(true, h.v2FinalizeCacheEntryUpload))
|
||||||
router.HandleFunc("POST "+cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
router.HandleFunc("POST "+cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.cacheModeAuth(false, h.v2GetCacheEntryDownloadURL))
|
||||||
router.HandleFunc("PUT "+blobPath+"/{id}", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
router.HandleFunc("PUT "+blobPath+"/{id}", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
// An exact (key, version) match means the entry is already cached; the client then skips
|
// An exact (key, version) match means the entry is already cached; the client then skips
|
||||||
// the upload. A prefix match must not count here, or a shorter key would be reported as
|
// the upload. A prefix match must not count here, or a shorter key would be reported as
|
||||||
// existing and silently never saved.
|
// existing and silently never saved.
|
||||||
if existing, err := findExactCache(db, cred.Repo, req.Key, req.Version, true); err != nil {
|
if existing, err := findExactCache(db, cred, req.Key, req.Version, true); err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
} else if existing != nil {
|
} else if existing != nil {
|
||||||
@@ -84,7 +85,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// A second live reservation is finalized by whichever job calls last, against the other's upload.
|
// A second live reservation is finalized by whichever job calls last, against the other's upload.
|
||||||
owner := hashedToken(bearerToken(r))
|
owner := hashedToken(bearerToken(r))
|
||||||
if pending, err := findExactCache(db, cred.Repo, req.Key, req.Version, false); err != nil {
|
if pending, err := findExactCache(db, cred, req.Key, req.Version, false); err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
|
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
|
||||||
@@ -103,6 +104,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
cache := &Cache{
|
cache := &Cache{
|
||||||
Repo: cred.Repo,
|
Repo: cred.Repo,
|
||||||
|
Ref: cred.Ref,
|
||||||
Key: req.Key,
|
Key: req.Key,
|
||||||
Version: req.Version,
|
Version: req.Version,
|
||||||
Owner: owner,
|
Owner: owner,
|
||||||
@@ -137,13 +139,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
cache, err := findExactCache(db, cred, req.Key, req.Version, false)
|
||||||
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
|
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
|
cache = nil // not the reservation this job made, so not this job's to commit
|
||||||
}
|
}
|
||||||
if err == nil && cache == nil {
|
if err == nil && cache == nil {
|
||||||
// A retry whose first response was lost finds the entry already committed.
|
// A retry whose first response was lost finds the entry already committed.
|
||||||
cache, err = findExactCache(db, cred.Repo, req.Key, req.Version, true)
|
cache, err = findExactCache(db, cred, req.Key, req.Version, true)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
@@ -186,7 +188,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := h.lookupCache(db, cred.Repo, req.keys(), req.Version)
|
cache, err := h.lookupCache(db, cred, req.keys(), req.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
@@ -217,6 +219,7 @@ func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.makeRoom(r.ContentLength)
|
||||||
|
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
switch strings.ToLower(query.Get("comp")) {
|
switch strings.ToLower(query.Get("comp")) {
|
||||||
@@ -265,6 +268,8 @@ func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string
|
|||||||
status = http.StatusServiceUnavailable
|
status = http.StatusServiceUnavailable
|
||||||
case twirpUnauthenticated:
|
case twirpUnauthenticated:
|
||||||
status = http.StatusUnauthorized
|
status = http.StatusUnauthorized
|
||||||
|
case twirpDenied:
|
||||||
|
status = http.StatusForbidden
|
||||||
}
|
}
|
||||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -84,6 +85,8 @@ func TestCacheServiceV2RoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
|
||||||
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
|
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
|
||||||
|
unreserved := handler.signedURL(JobCredential{Repo: testRepo}, blobPath, blobUploadPurpose, 1, time.Now().Add(time.Minute))
|
||||||
|
assert.Equal(t, http.StatusBadRequest, putBlob(t, unreserved, content))
|
||||||
|
|
||||||
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
|
||||||
require.Equal(t, true, finalized["ok"])
|
require.Equal(t, true, finalized["ok"])
|
||||||
@@ -224,6 +227,30 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
assert.NotEmpty(t, got["archiveLocation"])
|
assert.NotEmpty(t, got["archiveLocation"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("a read-only job only restores and a write-only one only saves", func(t *testing.T) {
|
||||||
|
handler.RegisterJob("read-only", JobCredential{Repo: testRepo, CacheMode: "read"})
|
||||||
|
readOnlyClient := &http.Client{Transport: &bearerTransport{token: "read-only"}}
|
||||||
|
|
||||||
|
assert.Equal(t, true, v2Call(t, handler, readOnlyClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})["ok"])
|
||||||
|
for _, method := range []string{"CreateCacheEntry", "FinalizeCacheEntryUpload"} {
|
||||||
|
got := v2Call(t, handler, readOnlyClient, method, map[string]any{"key": "deps-new", "version": "v1", "size_bytes": 1})
|
||||||
|
assert.Equal(t, false, got["ok"], method)
|
||||||
|
assert.True(t, strings.HasPrefix(got["message"].(string), "cache write denied:"), method)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.RegisterJob("write-only", JobCredential{Repo: testRepo, CacheMode: "write-only"})
|
||||||
|
writeOnlyClient := &http.Client{Transport: &bearerTransport{token: "write-only"}}
|
||||||
|
assert.Equal(t, true, v2Call(t, handler, writeOnlyClient, "CreateCacheEntry", map[string]any{"key": "deps-write-only", "version": "v1"})["ok"])
|
||||||
|
resp, err := writeOnlyClient.Post(handler.ExternalURL()+cacheServiceV2Path+"/GetCacheEntryDownloadURL", "application/json", strings.NewReader(`{"key":"deps-abc","version":"v1"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
got := map[string]string{}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
assert.Equal(t, twirpDenied, got["code"])
|
||||||
|
assert.True(t, strings.HasPrefix(got["msg"], "cache read denied:"), got["msg"])
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("another repository sees nothing", func(t *testing.T) {
|
t.Run("another repository sees nothing", func(t *testing.T) {
|
||||||
handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"})
|
handler.RegisterJob("other-runtime-token", JobCredential{Repo: "other/repo"})
|
||||||
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
|
||||||
@@ -231,6 +258,24 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
assert.Equal(t, false, got["ok"])
|
assert.Equal(t, false, got["ok"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("each ref saves and restores its own entry for the same key", func(t *testing.T) {
|
||||||
|
clients := map[string]*http.Client{"x": testClient}
|
||||||
|
for _, ref := range []string{"y", "z"} {
|
||||||
|
handler.RegisterJob(ref, JobCredential{Repo: testRepo, Ref: "refs/pull/" + ref + "/head"})
|
||||||
|
clients[ref] = &http.Client{Transport: &bearerTransport{token: ref}}
|
||||||
|
created := v2Call(t, handler, clients[ref], "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
require.Equal(t, true, created["ok"])
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, created["signed_upload_url"].(string), []byte(ref)))
|
||||||
|
}
|
||||||
|
for _, ref := range []string{"y", "z"} {
|
||||||
|
require.Equal(t, true, v2Call(t, handler, clients[ref], "FinalizeCacheEntryUpload", map[string]any{"key": "deps-abc", "version": "v1", "size_bytes": 1})["ok"])
|
||||||
|
}
|
||||||
|
for content, client := range clients {
|
||||||
|
got := v2Call(t, handler, client, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
|
||||||
|
assert.Equal(t, content, string(getURL(t, got["signed_download_url"].(string))))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
|
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Request struct {
|
|||||||
type Cache struct {
|
type Cache struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
|
Ref string `json:"ref,omitzero"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Size int64 `json:"cacheSize"`
|
Size int64 `json:"cacheSize"`
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
|
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if cred.InsecureTLS {
|
if proxy.Transport = h.instanceTransport; proxy.Transport == nil && cred.InsecureTLS { // the instance transport carries runner.insecure and the client cert
|
||||||
proxy.Transport = insecureTransport
|
proxy.Transport = insecureTransport
|
||||||
}
|
}
|
||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -133,3 +133,20 @@ func TestFrontResultsService(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFrontResultsServiceForwardsThroughTheInstanceTransport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
gitea := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _request *http.Request) { _, _ = io.WriteString(writer, "{}") }))
|
||||||
|
defer gitea.Close()
|
||||||
|
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InstanceTransport: gitea.Client().Transport})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer handler.Close()
|
||||||
|
defer handler.RegisterJob("token", JobCredential{Results: gitea.URL})()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+artifactServicePath+"CreateArtifact", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := (&http.Client{Transport: &bearerTransport{token: "token"}}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json/v2"
|
"encoding/json/v2"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -51,9 +52,10 @@ func (s *Storage) writeFile(name string, reader io.Reader) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(file, reader)
|
_, err = io.Copy(file, reader)
|
||||||
|
if err = errors.Join(err, file.Close()); err != nil {
|
||||||
|
_ = os.Remove(name)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,50 +104,46 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
written, err := assemble(name, tempNames)
|
written, err := assemble(name, tempNames)
|
||||||
if err != nil {
|
if err == nil && size >= 0 && written != size { // old clients like actions/cache@v2 send no size
|
||||||
return 0, err
|
err = fmt.Errorf("broken file: %v != %v", written, size)
|
||||||
}
|
}
|
||||||
// If size is less than 0, it means the size is unknown.
|
if err != nil {
|
||||||
// We can't check the size of the file, just skip the check.
|
|
||||||
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
|
|
||||||
if size >= 0 && written != size {
|
|
||||||
_ = os.Remove(name)
|
_ = os.Remove(name)
|
||||||
return 0, fmt.Errorf("broken file: %v != %v", written, size)
|
return 0, err
|
||||||
}
|
}
|
||||||
return written, nil
|
return written, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// assemble concatenates the uploaded parts into name. A single part, which is what the v2 API
|
// assemble appends the parts into the first, deleting each once copied, so it never needs twice the space.
|
||||||
// produces below the client's block threshold, is already the whole archive and is moved.
|
|
||||||
func assemble(name string, tempNames []string) (int64, error) {
|
func assemble(name string, tempNames []string) (int64, error) {
|
||||||
if len(tempNames) == 1 {
|
if len(tempNames) == 0 {
|
||||||
info, err := os.Stat(tempNames[0])
|
return 0, errors.New("nothing was uploaded")
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return info.Size(), os.Rename(tempNames[0], name)
|
|
||||||
}
|
}
|
||||||
|
if err := os.Rename(tempNames[0], name); err != nil {
|
||||||
file, err := os.Create(name)
|
return 0, err
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(name, os.O_WRONLY|os.O_APPEND, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
for _, v := range tempNames[1:] {
|
||||||
var written int64
|
part, err := os.Open(v)
|
||||||
for _, v := range tempNames {
|
|
||||||
f, err := os.Open(v)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
n, err := io.Copy(file, f)
|
_, err = io.Copy(file, part)
|
||||||
_ = f.Close()
|
_ = part.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
written += n
|
_ = os.Remove(v)
|
||||||
}
|
}
|
||||||
return written, nil
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return info.Size(), file.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
func (s *Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ func TestSafeResolve(t *testing.T) {
|
|||||||
|
|
||||||
for name, tc := range tests {
|
for name, tc := range tests {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
require.Equal(t, tc.want, safeResolve(baseDir, tc.input))
|
require.Equal(t, filepath.FromSlash(tc.want), safeResolve(baseDir, tc.input))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ package common
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common/logging"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
)
|
)
|
||||||
@@ -158,6 +160,28 @@ func (e Executor) IfBool(conditional bool) Executor {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryBackoff is the wait between Retry's attempts, random between 1 and 10 seconds like GitHub's runner.
|
||||||
|
var RetryBackoff = func() time.Duration { return time.Second + rand.N(9*time.Second) }
|
||||||
|
|
||||||
|
// Retry runs the executor up to attempts times until it succeeds.
|
||||||
|
func (e Executor) Retry(attempts int) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
for attempt := 1; ; attempt++ {
|
||||||
|
err := e(ctx)
|
||||||
|
if err == nil || attempt == attempts || ctx.Err() != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
backoff := RetryBackoff()
|
||||||
|
Logger(ctx).Warnf("%v, retrying in %s", err, backoff.Round(time.Second))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return err
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Finally adds an executor to run after other executor
|
// Finally adds an executor to run after other executor
|
||||||
func (e Executor) Finally(finally Executor) Executor {
|
func (e Executor) Finally(finally Executor) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -134,6 +135,31 @@ func TestExecutorConditionalsAndFinally(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"if-true", "if-not", "if-bool", "main", "finally"}, calls)
|
assert.Equal(t, []string{"if-true", "if-not", "if-bool", "main", "finally"}, calls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecutorRetryStopsOnSuccessOrAfterAllAttempts(t *testing.T) {
|
||||||
|
backoff := RetryBackoff
|
||||||
|
RetryBackoff = func() time.Duration { return 0 }
|
||||||
|
t.Cleanup(func() { RetryBackoff = backoff })
|
||||||
|
transient := errors.New("transient")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
failures, calls int
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{failures: 2, calls: 3},
|
||||||
|
{failures: 5, calls: 3, err: transient},
|
||||||
|
} {
|
||||||
|
var calls int
|
||||||
|
err := Executor(func(context.Context) error {
|
||||||
|
calls++
|
||||||
|
if calls <= tc.failures {
|
||||||
|
return transient
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}).Retry(3)(t.Context())
|
||||||
|
require.Equal(t, tc.err, err)
|
||||||
|
require.Equal(t, tc.calls, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
||||||
finalErr := errors.New("cleanup failed")
|
finalErr := errors.New("cleanup failed")
|
||||||
for _, mainErr := range []error{context.Canceled, nil} {
|
for _, mainErr := range []error{context.Canceled, nil} {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net"
|
"net"
|
||||||
@@ -29,6 +30,10 @@ import (
|
|||||||
|
|
||||||
var actionFetches sync.Map
|
var actionFetches sync.Map
|
||||||
|
|
||||||
|
var symlink = (*os.Root).Symlink
|
||||||
|
|
||||||
|
var symlinkFallback = runtime.GOOS == "windows"
|
||||||
|
|
||||||
const treeSizes = ".sizes" // beside the tree rather than in it, which is copied into job containers as it is
|
const treeSizes = ".sizes" // beside the tree rather than in it, which is copied into job containers as it is
|
||||||
|
|
||||||
type treeFile struct {
|
type treeFile struct {
|
||||||
@@ -229,6 +234,8 @@ func extractTree(ctx context.Context, archive io.Reader, dir string, strip bool)
|
|||||||
|
|
||||||
sha := ""
|
sha := ""
|
||||||
sizes := map[string]int64{}
|
sizes := map[string]int64{}
|
||||||
|
links := map[string]string{}
|
||||||
|
var deferred []string
|
||||||
reader := tar.NewReader(archive)
|
reader := tar.NewReader(archive)
|
||||||
for {
|
for {
|
||||||
header, err := reader.Next()
|
header, err := reader.Next()
|
||||||
@@ -257,9 +264,8 @@ func extractTree(ctx context.Context, archive io.Reader, dir string, strip bool)
|
|||||||
case tar.TypeReg:
|
case tar.TypeReg:
|
||||||
sizes[name], err = writeTreeFile(root, name, header.Mode, reader)
|
sizes[name], err = writeTreeFile(root, name, header.Mode, reader)
|
||||||
case tar.TypeSymlink:
|
case tar.TypeSymlink:
|
||||||
if err = root.Symlink(header.Linkname, name); err != nil && runtime.GOOS == "windows" {
|
links[filepath.Clean(name)] = header.Linkname
|
||||||
sizes[name], err = writeTreeFile(root, name, 0, strings.NewReader(header.Linkname))
|
deferred = append(deferred, filepath.Clean(name))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -268,12 +274,166 @@ func extractTree(ctx context.Context, archive io.Reader, dir string, strip bool)
|
|||||||
if !isFullObjectID(sha) {
|
if !isFullObjectID(sha) {
|
||||||
return "", errors.New("the archive names no commit")
|
return "", errors.New("the archive names no commit")
|
||||||
}
|
}
|
||||||
|
linksByParent := map[string][]string{}
|
||||||
|
if symlinkFallback {
|
||||||
|
for name := range links {
|
||||||
|
linksByParent[filepath.Dir(name)] = append(linksByParent[filepath.Dir(name)], filepath.Base(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for requireTarget := symlinkFallback; len(deferred) > 0; {
|
||||||
|
unresolved := deferred[:0]
|
||||||
|
for _, name := range deferred {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if requireTarget {
|
||||||
|
if _, err := root.Stat(filepath.Dir(name) + string(filepath.Separator) + links[name]); err != nil {
|
||||||
|
unresolved = append(unresolved, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := symlink(root, links[name], name); err == nil || !symlinkFallback {
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := copyTreeLink(ctx, root, name, links, linksByParent, sizes); err != nil {
|
||||||
|
return "", fmt.Errorf("materialize symlink %q: %w", filepath.ToSlash(name), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(unresolved) == len(deferred) {
|
||||||
|
requireTarget = false
|
||||||
|
}
|
||||||
|
deferred = unresolved
|
||||||
|
}
|
||||||
if err := root.Close(); err != nil {
|
if err := root.Close(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return sha, installTree(ctx, dir, sha, temp, sizes)
|
return sha, installTree(ctx, dir, sha, temp, sizes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveTreeLink(name string, links map[string]string) (string, bool) {
|
||||||
|
if !filepath.IsLocal(name) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
parts := strings.Split(name, string(filepath.Separator))
|
||||||
|
var resolved []string
|
||||||
|
for count := 0; len(parts) > 0; {
|
||||||
|
part := parts[0]
|
||||||
|
parts = parts[1:]
|
||||||
|
switch part {
|
||||||
|
case "", ".":
|
||||||
|
continue
|
||||||
|
case "..":
|
||||||
|
if len(resolved) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
resolved = resolved[:len(resolved)-1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target, ok := links[filepath.Join(append(resolved, part)...)]
|
||||||
|
if !ok {
|
||||||
|
resolved = append(resolved, part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if target == "" || os.IsPathSeparator(target[0]) || filepath.VolumeName(target) != "" || count > 255 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
parts = append(strings.Split(filepath.FromSlash(target), string(filepath.Separator)), parts...)
|
||||||
|
}
|
||||||
|
return filepath.Join(append([]string{"."}, resolved...)...), true
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errTreeLinkTarget = errors.New("unmaterializable symlink target")
|
||||||
|
errTreeLinkCycle = fmt.Errorf("cyclic %w", errTreeLinkTarget)
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyTreeLink(ctx context.Context, root *os.Root, name string, links map[string]string, linksByParent map[string][]string, sizes map[string]int64) error {
|
||||||
|
writeLinkText := func(destination, target string) error { // as git writes links it cannot create
|
||||||
|
if err := root.RemoveAll(destination); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for written := range sizes {
|
||||||
|
if written == destination || strings.HasPrefix(written, destination+string(filepath.Separator)) {
|
||||||
|
delete(sizes, written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size, err := writeTreeFile(root, destination, 0o644, strings.NewReader(target))
|
||||||
|
sizes[destination] = size
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
active := map[string]bool{}
|
||||||
|
var copyTarget func(string, string) error
|
||||||
|
copyTarget = func(source, destination string) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source, ok := resolveTreeLink(source, links)
|
||||||
|
if !ok {
|
||||||
|
return errTreeLinkTarget
|
||||||
|
}
|
||||||
|
file, err := root.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return errTreeLinkTarget
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Mode().IsRegular() {
|
||||||
|
sizes[destination], err = writeTreeFile(root, destination, int64(info.Mode().Perm()), file)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return errTreeLinkTarget
|
||||||
|
}
|
||||||
|
if active[source] {
|
||||||
|
return errTreeLinkCycle
|
||||||
|
}
|
||||||
|
active[source] = true
|
||||||
|
defer delete(active, source)
|
||||||
|
entries, err := file.ReadDir(-1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
children := map[string]bool{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
children[entry.Name()] = true
|
||||||
|
}
|
||||||
|
for _, child := range linksByParent[source] {
|
||||||
|
children[child] = true
|
||||||
|
}
|
||||||
|
if err := root.MkdirAll(destination, info.Mode().Perm()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for child := range children {
|
||||||
|
childSource, childDestination := filepath.Join(source, child), filepath.Join(destination, child)
|
||||||
|
err := copyTarget(childSource, childDestination)
|
||||||
|
if target, isLink := links[childSource]; isLink && errors.Is(err, errTreeLinkTarget) && !errors.Is(err, errTreeLinkCycle) {
|
||||||
|
err = writeLinkText(childDestination, target)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := copyTarget(name, name); !errors.Is(err, errTreeLinkTarget) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeLinkText(name, links[name])
|
||||||
|
}
|
||||||
|
|
||||||
var lockTree = AcquireCloneLock // replaced in tests to see installTree wait for a job holding the tree
|
var lockTree = AcquireCloneLock // replaced in tests to see installTree wait for a job holding the tree
|
||||||
|
|
||||||
// installTree moves temp into place as dir/sha, replacing a tree that failed treeIntact.
|
// installTree moves temp into place as dir/sha, replacing a tree that failed treeIntact.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cgi"
|
"net/http/cgi"
|
||||||
@@ -35,11 +36,12 @@ func TestFetchAction(t *testing.T) {
|
|||||||
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
remote := filepath.Join(root, "org", "action")
|
remote := filepath.Join(root, "org", "action")
|
||||||
require.NoError(t, gitCmd("init", "--initial-branch=main", remote))
|
require.NoError(t, os.CopyFS(remote, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755))
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755))
|
||||||
require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link")))
|
require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link")))
|
||||||
require.NoError(t, gitCmd("-C", remote, "add", "."))
|
require.NoError(t, gitCmd("-C", remote, "add", "."))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "update-index", "--chmod=+x", "run.sh"))
|
||||||
require.NoError(t, gitCmd("-C", remote, "commit", "-m", "initial"))
|
require.NoError(t, gitCmd("-C", remote, "commit", "-m", "initial"))
|
||||||
require.NoError(t, gitCmd("-C", remote, "tag", "v1"))
|
require.NoError(t, gitCmd("-C", remote, "tag", "v1"))
|
||||||
expectedSha := gitRevParse(t, remote, "main")
|
expectedSha := gitRevParse(t, remote, "main")
|
||||||
@@ -51,6 +53,7 @@ func TestFetchAction(t *testing.T) {
|
|||||||
var archiveRequests []http.Header
|
var archiveRequests []http.Header
|
||||||
var mirror *httptest.Server
|
var mirror *httptest.Server
|
||||||
archiveCache := map[string][]byte{}
|
archiveCache := map[string][]byte{}
|
||||||
|
revisions := map[string]string{}
|
||||||
requests, archives, failures := 0, "", 0
|
requests, archives, failures := 0, "", 0
|
||||||
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -77,7 +80,14 @@ func TestFetchAction(t *testing.T) {
|
|||||||
http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound)
|
http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sha, _ := gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
|
sha, ok := revisions[ref]
|
||||||
|
if !ok {
|
||||||
|
var err error
|
||||||
|
sha, err = gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
|
||||||
|
if err == nil {
|
||||||
|
revisions[ref] = sha
|
||||||
|
}
|
||||||
|
}
|
||||||
etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed")
|
etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed")
|
||||||
if request.Header.Get("If-None-Match") == etag {
|
if request.Header.Get("If-None-Match") == etag {
|
||||||
writer.WriteHeader(http.StatusNotModified)
|
writer.WriteHeader(http.StatusNotModified)
|
||||||
@@ -192,7 +202,9 @@ func TestFetchAction(t *testing.T) {
|
|||||||
assert.NoDirExists(t, filepath.Join(tree, ".git"))
|
assert.NoDirExists(t, filepath.Join(tree, ".git"))
|
||||||
info, err := os.Stat(filepath.Join(tree, "run.sh"))
|
info, err := os.Stat(filepath.Join(tree, "run.sh"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
if runtime.GOOS != "windows" {
|
||||||
|
assert.EqualValues(t, 0o755, info.Mode().Perm())
|
||||||
|
}
|
||||||
link, err := os.Readlink(filepath.Join(tree, "link"))
|
link, err := os.Readlink(filepath.Join(tree, "link"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "action.yml", link)
|
assert.Equal(t, "action.yml", link)
|
||||||
@@ -204,6 +216,9 @@ func TestFetchAction(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
|
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
|
||||||
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
|
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
|
||||||
expectedSha = gitRevParse(t, remote, "v1")
|
expectedSha = gitRevParse(t, remote, "v1")
|
||||||
|
mu.Lock()
|
||||||
|
clear(revisions)
|
||||||
|
mu.Unlock()
|
||||||
moved, movedSha, err := FetchAction(t.Context(), input)
|
moved, movedSha, err := FetchAction(t.Context(), input)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, expectedSha, movedSha)
|
assert.Equal(t, expectedSha, movedSha)
|
||||||
@@ -249,7 +264,7 @@ func TestFetchAction(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) {
|
t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) {
|
||||||
sha := gitRevParse(t, remote, "main")
|
sha := expectedSha
|
||||||
for _, mode := range []string{"prefixed", "none"} {
|
for _, mode := range []string{"prefixed", "none"} {
|
||||||
serve(mode, 0)
|
serve(mode, 0)
|
||||||
input := newInput(sha)
|
input := newInput(sha)
|
||||||
@@ -391,6 +406,79 @@ func TestExtractTreeConfinesEntries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractTreeCopiesDeferredLinks(t *testing.T) {
|
||||||
|
originalSymlink, originalFallback := symlink, symlinkFallback
|
||||||
|
symlink = func(_root *os.Root, _target, _name string) error { return errors.New("symlink privilege unavailable") }
|
||||||
|
symlinkFallback = true
|
||||||
|
t.Cleanup(func() { symlink, symlinkFallback = originalSymlink, originalFallback })
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
links [][2]string
|
||||||
|
files []string
|
||||||
|
text bool
|
||||||
|
}{
|
||||||
|
{name: "file chain", links: [][2]string{{"index.js", "entry.js"}, {"entry.js", "dist/index.js"}}, files: []string{"index.js", "entry.js"}},
|
||||||
|
{name: "directory chain", links: [][2]string{{"lib", "bundle"}, {"bundle", "dist"}, {"dist/entry.js", "index.js"}}, files: []string{"lib/index.js", "lib/entry.js", "lib/nested/index.js", "bundle/index.js", "bundle/entry.js", "bundle/nested/index.js"}},
|
||||||
|
{name: "directory in target path", links: [][2]string{{"index.js", "bundle/nested/index.js"}, {"bundle", "dist"}}, files: []string{"index.js", "bundle/nested/index.js"}},
|
||||||
|
{name: "parent after directory link", links: [][2]string{{"index.js", "bundle/../index.js"}, {"bundle", "dist/nested"}}, files: []string{"index.js", "bundle/index.js"}},
|
||||||
|
{name: "outside", links: [][2]string{{"index.js", "../outside.js"}}, text: true},
|
||||||
|
{name: "absolute", links: [][2]string{{"index.js", t.TempDir()}}, text: true},
|
||||||
|
{name: "dangling", links: [][2]string{{"index.js", "missing.js"}}, text: true},
|
||||||
|
{name: "dangling child of directory link", links: [][2]string{{"lib", "dist"}, {"dist/optional", "missing.js"}}, files: []string{"lib/index.js", "lib/nested/index.js"}},
|
||||||
|
{name: "cycle", links: [][2]string{{"index.js", "entry.js"}, {"entry.js", "index.js"}}, text: true},
|
||||||
|
{name: "directory cycle", links: [][2]string{{"dist/loop", "."}}, text: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
sha := strings.Repeat("a", 40)
|
||||||
|
var archive bytes.Buffer
|
||||||
|
writer := tar.NewWriter(&archive)
|
||||||
|
require.NoError(t, writer.WriteHeader(&tar.Header{Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": sha}}))
|
||||||
|
require.NoError(t, writer.WriteHeader(&tar.Header{Name: "dist", Typeflag: tar.TypeDir, Mode: 0o755}))
|
||||||
|
require.NoError(t, writer.WriteHeader(&tar.Header{Name: "dist/nested", Typeflag: tar.TypeDir, Mode: 0o755}))
|
||||||
|
for _, link := range test.links {
|
||||||
|
require.NoError(t, writer.WriteHeader(&tar.Header{Name: link[0], Typeflag: tar.TypeSymlink, Linkname: link[1]}))
|
||||||
|
}
|
||||||
|
content := "console.log('action');\n"
|
||||||
|
for _, name := range []string{"dist/index.js", "dist/nested/index.js"} {
|
||||||
|
require.NoError(t, writer.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(content))}))
|
||||||
|
_, err := writer.Write([]byte(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := extractTree(t.Context(), &archive, dir, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, treeIntact(dir, sha))
|
||||||
|
if test.text {
|
||||||
|
for _, link := range test.links {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, sha, link[0]))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, link[1], string(data))
|
||||||
|
info, err := os.Lstat(filepath.Join(dir, sha, link[0]))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, info.Mode().IsRegular())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range test.files {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, sha, name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, content, string(data))
|
||||||
|
info, err := os.Lstat(filepath.Join(dir, sha, name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, info.Mode().IsRegular())
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, sha, name), nil, 0o644))
|
||||||
|
assert.False(t, treeIntact(dir, sha), name)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, sha, name), []byte(content), 0o755))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTreeIntactFindsNamesThatAreNotUTF8(t *testing.T) {
|
func TestTreeIntactFindsNamesThatAreNotUTF8(t *testing.T) {
|
||||||
if runtime.GOOS != "linux" {
|
if runtime.GOOS != "linux" {
|
||||||
t.Skip("macOS and Windows store no name that is not UTF-8")
|
t.Skip("macOS and Windows store no name that is not UTF-8")
|
||||||
@@ -410,3 +498,76 @@ func TestTreeIntactFindsNamesThatAreNotUTF8(t *testing.T) {
|
|||||||
require.FileExists(t, filepath.Join(dir, sha, "caf\xe9.js"))
|
require.FileExists(t, filepath.Join(dir, sha, "caf\xe9.js"))
|
||||||
assert.True(t, treeIntact(dir, sha))
|
assert.True(t, treeIntact(dir, sha))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractTreeCreatesLinksInPlatformOrder(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("requires symlink privileges")
|
||||||
|
}
|
||||||
|
original, originalFallback := symlink, symlinkFallback
|
||||||
|
var created []string
|
||||||
|
symlink = func(root *os.Root, target, name string) error {
|
||||||
|
created = append(created, name)
|
||||||
|
return root.Symlink(target, name)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { symlink, symlinkFallback = original, originalFallback })
|
||||||
|
|
||||||
|
var archive bytes.Buffer
|
||||||
|
writer := tar.NewWriter(&archive)
|
||||||
|
require.NoError(t, writer.WriteHeader(&tar.Header{Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": strings.Repeat("a", 40)}}))
|
||||||
|
for _, entry := range []*tar.Header{
|
||||||
|
{Name: "a", Typeflag: tar.TypeSymlink, Linkname: "b"},
|
||||||
|
{Name: "b", Typeflag: tar.TypeSymlink, Linkname: "dist"},
|
||||||
|
{Name: "dangling", Typeflag: tar.TypeSymlink, Linkname: "missing"},
|
||||||
|
{Name: "dist", Typeflag: tar.TypeDir, Mode: 0o755},
|
||||||
|
} {
|
||||||
|
require.NoError(t, writer.WriteHeader(entry))
|
||||||
|
}
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
fallback bool
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "native", want: []string{"a", "b", "dangling"}},
|
||||||
|
{name: "fallback", fallback: true, want: []string{"b", "a", "dangling"}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
symlinkFallback = test.fallback
|
||||||
|
created = nil
|
||||||
|
_, err := extractTree(t.Context(), bytes.NewReader(archive.Bytes()), t.TempDir(), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.want, created)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyTreeLinkCancellationBetweenEntries(t *testing.T) {
|
||||||
|
root, err := os.OpenRoot(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer root.Close()
|
||||||
|
require.NoError(t, root.Mkdir("source", 0o755))
|
||||||
|
for _, name := range []string{"first", "second"} {
|
||||||
|
_, err := writeTreeFile(root, filepath.Join("source", name), 0o644, strings.NewReader("content"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
sizes := map[string]int64{}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
observed := &treeCopyContext{Context: ctx, check: func() {
|
||||||
|
if len(sizes) == 1 {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
require.ErrorIs(t, copyTreeLink(observed, root, "copy", map[string]string{"copy": "source"}, nil, sizes), context.Canceled)
|
||||||
|
assert.Len(t, sizes, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type treeCopyContext struct {
|
||||||
|
context.Context
|
||||||
|
check func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *treeCopyContext) Err() error {
|
||||||
|
ctx.check()
|
||||||
|
return ctx.Context.Err()
|
||||||
|
}
|
||||||
+37
-8
@@ -5,6 +5,7 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -27,6 +28,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/logging"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -183,6 +185,7 @@ type NewGitCloneExecutorInput struct {
|
|||||||
// For Gitea
|
// For Gitea
|
||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories
|
ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories
|
||||||
|
CAFile string // PEM, absolute, replaces git's CA bundle
|
||||||
Transport http.RoundTripper // for FetchAction's archive downloads, nil for http.DefaultTransport
|
Transport http.RoundTripper // for FetchAction's archive downloads, nil for http.DefaultTransport
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +390,21 @@ func RemoteConfig(token string, insecureSkipTLS bool) []string {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSLConfig uses OpenSSL on Windows, as Schannel takes no PEM key and ignores sslCAInfo.
|
||||||
|
func SSLConfig(prefix, certFile, keyFile, caFile, executionOS string) []string {
|
||||||
|
var config []string
|
||||||
|
if certFile != "" {
|
||||||
|
config = append(config, prefix+"sslCert="+certFile, prefix+"sslKey="+keyFile)
|
||||||
|
}
|
||||||
|
if caFile != "" {
|
||||||
|
config = append(config, prefix+"sslCAInfo="+caFile)
|
||||||
|
}
|
||||||
|
if config != nil && strings.EqualFold(executionOS, "windows") {
|
||||||
|
config = append(config, prefix+"sslBackend=openssl")
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
// ConfigEnv applies `-c` style entries through the environment, keeping secrets out of argv.
|
// ConfigEnv applies `-c` style entries through the environment, keeping secrets out of argv.
|
||||||
func ConfigEnv(config ...string) map[string]string {
|
func ConfigEnv(config ...string) map[string]string {
|
||||||
env := map[string]string{"GIT_CONFIG_COUNT": strconv.Itoa(len(config))}
|
env := map[string]string{"GIT_CONFIG_COUNT": strconv.Itoa(len(config))}
|
||||||
@@ -444,9 +462,7 @@ func runGitWithStdin(ctx context.Context, dir string, input *NewGitCloneExecutor
|
|||||||
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
|
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
|
||||||
if input != nil {
|
if input != nil {
|
||||||
config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...)
|
config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...)
|
||||||
if input.ClientCertFile != "" {
|
config = append(config, SSLConfig("http.", input.ClientCertFile, input.ClientKeyFile, input.CAFile, runtime.GOOS)...)
|
||||||
config = append(config, "http.sslCert="+input.ClientCertFile, "http.sslKey="+input.ClientKeyFile)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
args = append([]string{"-C", dir}, args...)
|
args = append([]string{"-C", dir}, args...)
|
||||||
@@ -461,15 +477,28 @@ func runGitWithStdin(ctx context.Context, dir string, input *NewGitCloneExecutor
|
|||||||
cmd.Env = append(cmd.Env, key+"="+value)
|
cmd.Env = append(cmd.Env, key+"="+value)
|
||||||
}
|
}
|
||||||
cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0")
|
cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0")
|
||||||
if runtime.GOOS != "windows" {
|
var treeKill *process.TreeKill
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
treeKill = process.NewTreeKill(cmd) // a cancelled git leaves its remote helper holding the repository otherwise
|
||||||
|
} else {
|
||||||
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } // lets git remove its lock files
|
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } // lets git remove its lock files
|
||||||
}
|
}
|
||||||
cmd.WaitDelay = time.Second
|
cmd.WaitDelay = time.Second
|
||||||
|
var stdout bytes.Buffer
|
||||||
var stderr strings.Builder
|
var stderr strings.Builder
|
||||||
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
output, err := cmd.Output()
|
err := cmd.Start()
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return output, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
|
if treeKill != nil {
|
||||||
|
if killer, captureErr := treeKill.Capture(cmd.Process); captureErr == nil {
|
||||||
|
defer killer.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = cmd.Wait()
|
||||||
}
|
}
|
||||||
return output, nil
|
if err != nil {
|
||||||
|
return stdout.Bytes(), fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
+46
-35
@@ -54,6 +54,22 @@ func TestMain(m *testing.M) {
|
|||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSSLConfig(t *testing.T) {
|
||||||
|
for _, testcase := range []struct {
|
||||||
|
prefix, certFile, caFile, executionOS string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{"http.https://example.com/.", "cert.pem", "ca.pem", "Windows", []string{"http.https://example.com/.sslCert=cert.pem", "http.https://example.com/.sslKey=key.pem", "http.https://example.com/.sslCAInfo=ca.pem", "http.https://example.com/.sslBackend=openssl"}},
|
||||||
|
{"http.", "", "ca.pem", "windows", []string{"http.sslCAInfo=ca.pem", "http.sslBackend=openssl"}},
|
||||||
|
{"http.", "cert.pem", "ca.pem", "Linux", []string{"http.sslCert=cert.pem", "http.sslKey=key.pem", "http.sslCAInfo=ca.pem"}},
|
||||||
|
{"http.", "", "", "Windows", nil},
|
||||||
|
} {
|
||||||
|
t.Run(testcase.prefix+testcase.certFile+testcase.caFile+testcase.executionOS, func(t *testing.T) {
|
||||||
|
assert.Equal(t, testcase.expected, SSLConfig(testcase.prefix, testcase.certFile, "key.pem", testcase.caFile, testcase.executionOS))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFindGitSlug(t *testing.T) {
|
func TestFindGitSlug(t *testing.T) {
|
||||||
for _, testcase := range []struct {
|
for _, testcase := range []struct {
|
||||||
remoteURL string
|
remoteURL string
|
||||||
@@ -96,8 +112,7 @@ func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
basedir := t.TempDir()
|
basedir := t.TempDir()
|
||||||
const remoteURL = "https://github.com/owner/repo.git"
|
const remoteURL = "https://github.com/owner/repo.git"
|
||||||
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir))
|
require.NoError(t, gitCmd("init", "--template=", "--object-format=sha256", "--initial-branch=main", basedir))
|
||||||
require.NoError(t, os.RemoveAll(filepath.Join(basedir, ".git", "hooks")))
|
|
||||||
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
|
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
|
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
|
||||||
@@ -185,22 +200,20 @@ func TestGitFindRef(t *testing.T) {
|
|||||||
func TestGitCloneExecutor(t *testing.T) {
|
func TestGitCloneExecutor(t *testing.T) {
|
||||||
remoteDir, workDir := newGitRemote(t)
|
remoteDir, workDir := newGitRemote(t)
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755))
|
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh"))
|
require.NoError(t, os.WriteFile(filepath.Join(workDir, ".gitattributes"), []byte("*.sh text\n"), 0o644))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh", ".gitattributes"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
|
||||||
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
|
|
||||||
|
|
||||||
fullSha := gitRevParse(t, workDir, "main")
|
fullSha := gitRevParse(t, workDir, "main")
|
||||||
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4]))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", "v2", "act-fails", "main:refs/heads/"+fullSha[:4]))
|
||||||
|
|
||||||
hostConfig := filepath.Join(t.TempDir(), "gitconfig")
|
hostConfig := filepath.Join(t.TempDir(), "gitconfig")
|
||||||
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n[url \""+remoteDir+"\"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
|
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n\teol = crlf\n[url "+strconv.Quote(remoteDir)+"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
|
||||||
t.Setenv("GIT_CONFIG_GLOBAL", hostConfig)
|
t.Setenv("GIT_CONFIG_GLOBAL", hostConfig)
|
||||||
t.Setenv("GIT_DIR", t.TempDir())
|
t.Setenv("GIT_DIR", t.TempDir())
|
||||||
|
|
||||||
@@ -265,10 +278,10 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
createRemote := func(message string) string {
|
createRemote := func(message string) string {
|
||||||
remoteDir, workDir := newGitRemote(t, message)
|
dir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", message))
|
||||||
return remoteDir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
oldRemoteDir := createRemote("old-action")
|
oldRemoteDir := createRemote("old-action")
|
||||||
@@ -388,31 +401,30 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3")
|
remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", "v1"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
|
||||||
|
|
||||||
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
||||||
|
|
||||||
for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} {
|
for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir, Ref: ref, Dir: dir, Depth: 1,
|
URL: remoteDir, Ref: ref, Dir: dir, Depth: 1,
|
||||||
})(t.Context()))
|
})(t.Context()))
|
||||||
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||||
assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present")
|
assert.Equal(t, []string{"c3"}, gitSubjects(t, dir), "only the tip commit should be present")
|
||||||
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
|
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
|
||||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||||
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
|
require.NoError(t, gitCmd("clone", "--bare", "--template=", "--config", "uploadpack.allowAnySHA1InWant="+strconv.FormatBool(allowUnadvertised), remoteDir, remote))
|
||||||
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
|
|
||||||
return remote
|
return remote
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
|
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||||
@@ -421,6 +433,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
|
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
remote := unadvertisedRemote(t, true)
|
remote := unadvertisedRemote(t, true)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cloneAt := func(ref string) error {
|
cloneAt := func(ref string) error {
|
||||||
@@ -431,7 +444,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, cloneAt(unadvertisedSHA))
|
require.NoError(t, cloneAt(unadvertisedSHA))
|
||||||
assert.FileExists(t, shallowMarker(dir))
|
assert.FileExists(t, shallowMarker(dir))
|
||||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
assert.Len(t, gitSubjects(t, dir), 1)
|
||||||
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
olderSHA := gitRevParse(t, workDir, "HEAD~2")
|
olderSHA := gitRevParse(t, workDir, "HEAD~2")
|
||||||
@@ -444,6 +457,8 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
|
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
remoteDir, workDir := newGitRemote(t, "c2", "c3")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
@@ -456,19 +471,17 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||||
|
|
||||||
require.NoError(t, clone(t.Context()))
|
require.NoError(t, clone(t.Context()))
|
||||||
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
assert.Equal(t, []string{"c4"}, gitSubjects(t, dir), "reused shallow clone should update to the new tip")
|
||||||
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
|
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--object-format=sha256", "--initial-branch=main", remote))
|
|
||||||
work := t.TempDir()
|
work := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", work))
|
require.NoError(t, gitCmd("init", "--template=", "--object-format=sha256", "--initial-branch=main", work))
|
||||||
require.NoError(t, gitCmd("-C", work, "remote", "add", "origin", remote))
|
|
||||||
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
|
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
|
||||||
require.NoError(t, gitCmd("-C", work, "push", "origin", "main"))
|
require.NoError(t, gitCmd("clone", "--bare", "--template=", work, remote))
|
||||||
sha := gitRevParse(t, work, "HEAD")
|
sha := gitRevParse(t, work, "HEAD")
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
|
||||||
@@ -487,11 +500,11 @@ func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
|||||||
pinned := gitRevParse(t, workDir, "HEAD")
|
pinned := gitRevParse(t, workDir, "HEAD")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
|
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, "tag", pinned))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", pinned))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
|
|
||||||
|
|
||||||
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
|
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
|
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
|
||||||
@@ -523,13 +536,11 @@ func gitRevParse(t *testing.T, dir, rev string) string {
|
|||||||
return strings.TrimSpace(string(out))
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitRevCount(t *testing.T, dir string) int {
|
func gitSubjects(t *testing.T, dir string) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
|
out, err := exec.Command("git", "-C", dir, "log", "--format=%s").Output()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
return strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
require.NoError(t, err)
|
|
||||||
return n
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitHeadSubject(t *testing.T, dir string) string {
|
func gitHeadSubject(t *testing.T, dir string) string {
|
||||||
@@ -663,7 +674,7 @@ func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "cached-action")
|
dir := filepath.Join(t.TempDir(), "cached-action")
|
||||||
require.NoError(t, gitCmd("init", dir))
|
require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
|
||||||
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
|
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
@@ -676,8 +687,8 @@ func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-reached:
|
case <-reached:
|
||||||
case <-time.After(10 * time.Second):
|
case err := <-done:
|
||||||
t.Fatal("the executor never reached the remote")
|
t.Fatalf("the executor finished before reaching the remote: %v", err)
|
||||||
}
|
}
|
||||||
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), authorization)
|
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), authorization)
|
||||||
cancel()
|
cancel()
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// LookupFold finds name in values, ignoring case where the environment does, as GitHub's inputs always do.
|
||||||
|
func LookupFold[V any](values map[string]V, name string, caseInsensitive bool) (V, bool) {
|
||||||
|
if value, ok := values[name]; ok || !caseInsensitive {
|
||||||
|
return value, ok
|
||||||
|
}
|
||||||
|
for key, value := range values {
|
||||||
|
if strings.EqualFold(key, name) {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var zero V
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -55,19 +56,22 @@ func GetOutboundIP() net.IP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preferredIP(ips []interfaceIP) net.IP {
|
func preferredIP(ips []interfaceIP) net.IP {
|
||||||
|
ips = slices.DeleteFunc(ips, func(ip interfaceIP) bool {
|
||||||
|
return len(ip.Name) == 15 && strings.HasPrefix(ip.Name, "br-") && strings.Trim(ip.Name[3:], "0123456789abcdef") == "" // a Docker network's bridge, removed with the network
|
||||||
|
})
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
sort.Slice(ips, func(i, j int) bool {
|
sort.Slice(ips, func(i, j int) bool {
|
||||||
first, second := ips[i], ips[j]
|
first, second := ips[i], ips[j]
|
||||||
|
|
||||||
// ethernet is preferred
|
// IPv4 is preferred, as job networks are IPv4-only by default
|
||||||
if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
|
if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
|
||||||
return vi
|
return vi
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv4 is preferred
|
// ethernet is preferred
|
||||||
if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
|
if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
|
||||||
return vi
|
return vi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import (
|
|||||||
func TestPreferredIP(t *testing.T) {
|
func TestPreferredIP(t *testing.T) {
|
||||||
assert.Nil(t, preferredIP(nil))
|
assert.Nil(t, preferredIP(nil))
|
||||||
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}}))
|
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}}))
|
||||||
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{
|
assert.Equal(t, net.ParseIP("192.0.2.1"), preferredIP([]interfaceIP{
|
||||||
{IP: net.ParseIP("192.0.2.1"), Name: "wlan0"},
|
{IP: net.ParseIP("192.0.2.1"), Name: "wlan0"},
|
||||||
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
||||||
|
{IP: net.ParseIP("172.18.0.1"), Name: "br-0123456789ab"},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -10,9 +10,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path"
|
"path"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -20,6 +22,11 @@ import (
|
|||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var hostOS = runtime.GOOS // replaced in tests
|
||||||
|
|
||||||
|
// DrainGracePeriod caps the wait for container output to drain so the log tail is not truncated
|
||||||
|
const DrainGracePeriod = 2 * time.Second
|
||||||
|
|
||||||
// ExitCodeError reports a non-zero process exit code from a container command.
|
// ExitCodeError reports a non-zero process exit code from a container command.
|
||||||
type ExitCodeError int
|
type ExitCodeError int
|
||||||
|
|
||||||
@@ -145,6 +152,8 @@ type NewDockerBuildExecutorInput struct {
|
|||||||
ContextDir string
|
ContextDir string
|
||||||
Dockerfile string
|
Dockerfile string
|
||||||
BuildContext io.Reader
|
BuildContext io.Reader
|
||||||
|
Dockerignore []byte // applied to BuildContext as the docker CLI applies it
|
||||||
|
Stdout io.Writer
|
||||||
ImageTag string
|
ImageTag string
|
||||||
Platform string
|
Platform string
|
||||||
BuildArgs map[string]*string
|
BuildArgs map[string]*string
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
// Copyright 2021 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !windows && !(WITHOUT_DOCKER || !(linux || darwin || netbsd))
|
//go:build !windows && !(WITHOUT_DOCKER || !(linux || darwin || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -48,6 +51,7 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
|||||||
|
|
||||||
options := client.ImageBuildOptions{
|
options := client.ImageBuildOptions{
|
||||||
Tags: []string{input.ImageTag},
|
Tags: []string{input.ImageTag},
|
||||||
|
Labels: map[string]string{"com.gitea.runner.image": input.ImageTag}, // own image ID per tag, so removing one never deletes another job's identical build
|
||||||
Remove: true,
|
Remove: true,
|
||||||
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||||
Dockerfile: input.Dockerfile,
|
Dockerfile: input.Dockerfile,
|
||||||
@@ -62,7 +66,7 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
var buildContext io.ReadCloser
|
var buildContext io.ReadCloser
|
||||||
if input.BuildContext != nil {
|
if input.BuildContext != nil {
|
||||||
buildContext = io.NopCloser(input.BuildContext)
|
buildContext, err = filterBuildContext(input.BuildContext, input.Dockerignore, input.Dockerfile)
|
||||||
} else {
|
} else {
|
||||||
buildContext, err = createBuildContext(ctx, input.ContextDir, input.Dockerfile)
|
buildContext, err = createBuildContext(ctx, input.ContextDir, input.Dockerfile)
|
||||||
}
|
}
|
||||||
@@ -77,10 +81,97 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return logDockerResponse(logger, resp.Body, false)
|
err = logDockerResponse(logger, resp.Body, func(format string, args ...any) { fmt.Fprintf(input.Stdout, format, args...) })
|
||||||
|
common.FlushWriter(input.Stdout)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterBuildContext(buildContext io.Reader, dockerignore []byte, relDockerfile string) (io.ReadCloser, error) {
|
||||||
|
excludes, err := ignorefile.ReadAll(bytes.NewReader(dockerignore))
|
||||||
|
if err != nil || len(excludes) == 0 {
|
||||||
|
return io.NopCloser(buildContext), err
|
||||||
|
}
|
||||||
|
pm, err := patternmatcher.New(excludes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
writer.CloseWithError(writeFilteredBuildContext(writer, buildContext, pm, path.Clean(filepath.ToSlash(relDockerfile))))
|
||||||
|
}()
|
||||||
|
return reader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFilteredBuildContext(w io.Writer, buildContext io.Reader, pm *patternmatcher.PatternMatcher, relDockerfile string) error {
|
||||||
|
spool, err := os.CreateTemp("", "gitea-runner-build-") // excluded content, for a kept hardlink to it
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(spool.Name())
|
||||||
|
defer spool.Close()
|
||||||
|
|
||||||
|
reader := tar.NewReader(buildContext)
|
||||||
|
writer := tar.NewWriter(w)
|
||||||
|
buffer := make([]byte, 32*1024)
|
||||||
|
excluded := map[string]*excludedBuildContextEntry{}
|
||||||
|
for {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return writer.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := path.Clean(header.Name)
|
||||||
|
var target *excludedBuildContextEntry
|
||||||
|
if header.Typeflag == tar.TypeLink {
|
||||||
|
target = excluded[path.Clean(header.Linkname)]
|
||||||
|
}
|
||||||
|
if name != ".dockerignore" && name != relDockerfile {
|
||||||
|
skip, err := pm.MatchesOrParentMatches(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
if target != nil {
|
||||||
|
excluded[name] = target
|
||||||
|
} else if header.Typeflag != tar.TypeDir {
|
||||||
|
offset, err := spool.Seek(0, io.SeekCurrent)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
excluded[name] = &excludedBuildContextEntry{header: header, offset: offset}
|
||||||
|
if _, err := io.CopyBuffer(spool, reader, buffer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var content io.Reader = reader
|
||||||
|
if target != nil && target.keptName != "" {
|
||||||
|
header.Linkname = target.keptName
|
||||||
|
} else if target != nil {
|
||||||
|
header.Typeflag, header.Linkname, header.Size = target.header.Typeflag, target.header.Linkname, target.header.Size
|
||||||
|
content = io.NewSectionReader(spool, target.offset, header.Size)
|
||||||
|
target.keptName = header.Name
|
||||||
|
}
|
||||||
|
if err := writer.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.CopyBuffer(writer, content, buffer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type excludedBuildContextEntry struct {
|
||||||
|
header *tar.Header
|
||||||
|
offset int64
|
||||||
|
keptName string
|
||||||
|
}
|
||||||
|
|
||||||
func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (io.ReadCloser, error) {
|
func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (io.ReadCloser, error) {
|
||||||
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
||||||
|
|
||||||
@@ -125,6 +216,10 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeBuildContext(ctx context.Context, w io.Writer, contextDir string, includes []string, pm *patternmatcher.PatternMatcher) error {
|
func writeBuildContext(ctx context.Context, w io.Writer, contextDir string, includes []string, pm *patternmatcher.PatternMatcher) error {
|
||||||
|
contextDir, err := filepath.EvalSymlinks(contextDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
tw := tar.NewWriter(w)
|
tw := tar.NewWriter(w)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
hardlinks := map[uint64]string{}
|
hardlinks := map[uint64]string{}
|
||||||
@@ -132,8 +227,7 @@ func writeBuildContext(ctx context.Context, w io.Writer, contextDir string, incl
|
|||||||
for _, include := range includes {
|
for _, include := range includes {
|
||||||
include = filepath.FromSlash(include)
|
include = filepath.FromSlash(include)
|
||||||
var parents []parentMatch
|
var parents []parentMatch
|
||||||
root := strings.TrimSuffix(contextDir, string(filepath.Separator)) + string(filepath.Separator) + include // unlike filepath.Join, follows a symlinked context dir as docker build does
|
err := filepath.WalkDir(filepath.Join(contextDir, include), func(file string, entry fs.DirEntry, err error) error {
|
||||||
err := filepath.WalkDir(root, func(file string, entry fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return leaveOut(ctx, file, err)
|
return leaveOut(ctx, file, err)
|
||||||
}
|
}
|
||||||
@@ -210,7 +304,7 @@ func addBuildContextEntry(ctx context.Context, tw *tar.Writer, file, rel string,
|
|||||||
return leaveOut(ctx, file, err)
|
return leaveOut(ctx, file, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
header, err := tar.FileInfoHeader(noNameLookups{info}, link)
|
header, err := tar.FileInfoHeader(noNameLookups{info}, filepath.ToSlash(link))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -18,10 +20,12 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDockerBuildReportsDaemonError(t *testing.T) {
|
func TestDockerBuildLabelsImageWithItsTagAndReportsDaemonError(t *testing.T) {
|
||||||
|
var labels string
|
||||||
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
writer.Header().Set("API-Version", "1.47")
|
writer.Header().Set("API-Version", "1.47")
|
||||||
if strings.HasSuffix(request.URL.Path, "/build") {
|
if strings.HasSuffix(request.URL.Path, "/build") {
|
||||||
|
labels = request.URL.Query().Get("labels")
|
||||||
writer.Header().Set("Content-Type", "application/json")
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = io.WriteString(writer, `{"message":"build rejected"}`)
|
_, _ = io.WriteString(writer, `{"message":"build rejected"}`)
|
||||||
@@ -33,24 +37,25 @@ func TestDockerBuildReportsDaemonError(t *testing.T) {
|
|||||||
t.Setenv("DOCKER_AUTH_CONFIG", "")
|
t.Setenv("DOCKER_AUTH_CONFIG", "")
|
||||||
|
|
||||||
require.ErrorContains(t, NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
require.ErrorContains(t, NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||||
ImageTag: "action:test", Dockerfile: "Dockerfile", BuildContext: strings.NewReader(""),
|
ImageTag: "action:test", Dockerfile: "Dockerfile", BuildContext: strings.NewReader(""), Stdout: io.Discard,
|
||||||
})(t.Context()), "build rejected")
|
})(t.Context()), "build rejected")
|
||||||
|
require.JSONEq(t, `{"com.gitea.runner.image":"action:test"}`, labels)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) {
|
func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(dir, "build"), 0o755))
|
||||||
for name, content := range map[string]string{
|
for name, content := range map[string]string{
|
||||||
".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a",
|
".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a",
|
||||||
"a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep",
|
"a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep",
|
||||||
} {
|
} {
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(name)), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
||||||
}
|
}
|
||||||
require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755))
|
require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755))
|
||||||
|
|
||||||
contexts := []string{dir}
|
contexts := []string{dir}
|
||||||
if link := filepath.Join(t.TempDir(), "link"); os.Symlink(dir, link) == nil {
|
if link := filepath.Join(t.TempDir(), "link"); os.Symlink(dir, link) == nil {
|
||||||
contexts = append(contexts, link+string(filepath.Separator))
|
contexts = append(contexts, link, link+string(filepath.Separator))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, contextDir := range contexts {
|
for _, contextDir := range contexts {
|
||||||
@@ -69,3 +74,56 @@ func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile
|
|||||||
require.Equal(t, []string{".dockerignore", "a.txt", "build/keep.txt", "empty/", "Dockerfile"}, names, contextDir)
|
require.Equal(t, []string{".dockerignore", "a.txt", "build/keep.txt", "empty/", "Dockerfile"}, names, contextDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterBuildContextAppliesDockerignoreAndMaterializesHardlinksToExcludedFiles(t *testing.T) {
|
||||||
|
const dockerignore = "*.log\nbuild\n!build/keep.txt\nActionfile\n.dockerignore\n"
|
||||||
|
for _, prefix := range []string{"", "./"} {
|
||||||
|
var archive bytes.Buffer
|
||||||
|
writer := tar.NewWriter(&archive)
|
||||||
|
for _, entry := range []struct {
|
||||||
|
name, body, link string
|
||||||
|
typeflag byte
|
||||||
|
}{
|
||||||
|
{"Actionfile", "FROM scratch", "", tar.TypeReg},
|
||||||
|
{"output.log", "log", "", tar.TypeReg},
|
||||||
|
{"build/", "", "", tar.TypeDir},
|
||||||
|
{"build/drop.txt", "drop", "", tar.TypeReg},
|
||||||
|
{"build/keep.txt", "keep", "", tar.TypeReg},
|
||||||
|
{"entrypoint", "", prefix + "output.log", tar.TypeLink},
|
||||||
|
{"other-entrypoint", "", prefix + "output.log", tar.TypeLink},
|
||||||
|
{".dockerignore", dockerignore, "", tar.TypeReg},
|
||||||
|
} {
|
||||||
|
require.NoError(t, writer.WriteHeader(&tar.Header{Name: prefix + entry.name, Linkname: entry.link, Typeflag: entry.typeflag, Size: int64(len(entry.body)), Mode: 0o644}))
|
||||||
|
_, err := io.WriteString(writer, entry.body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
unfiltered, err := filterBuildContext(bytes.NewReader(archive.Bytes()), nil, "Actionfile")
|
||||||
|
require.NoError(t, err)
|
||||||
|
passedThrough, err := io.ReadAll(unfiltered)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, archive.Bytes(), passedThrough)
|
||||||
|
|
||||||
|
filtered, err := filterBuildContext(bytes.NewReader(archive.Bytes()), []byte(dockerignore), "Actionfile")
|
||||||
|
require.NoError(t, err)
|
||||||
|
var entries []string
|
||||||
|
for reader := tar.NewReader(filtered); ; {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
entries = append(entries, fmt.Sprintf("%s %c %s %s", strings.TrimPrefix(header.Name, prefix), header.Typeflag, strings.TrimPrefix(header.Linkname, prefix), body))
|
||||||
|
}
|
||||||
|
require.Equal(t, []string{
|
||||||
|
"Actionfile 0 FROM scratch",
|
||||||
|
"build/keep.txt 0 keep",
|
||||||
|
"entrypoint 0 log",
|
||||||
|
"other-entrypoint 1 entrypoint ",
|
||||||
|
".dockerignore 0 " + dockerignore,
|
||||||
|
}, entries, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd || freebsd)
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd || freebsd)
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
// This file is based on https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
|
// This file is based on https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
@@ -351,7 +351,7 @@ type containerConfig struct {
|
|||||||
// parse parses the args for the specified command and generates a Config,
|
// parse parses the args for the specified command and generates a Config,
|
||||||
// a HostConfig and returns them with the specified command.
|
// a HostConfig and returns them with the specified command.
|
||||||
// If the specified args are not valid, it will return an error.
|
// If the specified args are not valid, it will return an error.
|
||||||
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { //nolint:gocyclo,unparam // verbatim copy from docker/cli
|
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) { //nolint:gocyclo // verbatim copy from docker/cli
|
||||||
var (
|
var (
|
||||||
attachStdin = copts.attach.Get("stdin")
|
attachStdin = copts.attach.Get("stdin")
|
||||||
attachStdout = copts.attach.Get("stdout")
|
attachStdout = copts.attach.Get("stdout")
|
||||||
@@ -724,6 +724,10 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isUserDefinedNetwork(mode container.NetworkMode) bool { // Docker's helper uses the host OS instead of the daemon's.
|
||||||
|
return !mode.IsDefault() && mode != network.NetworkBridge && mode != network.NetworkHost && !mode.IsNone() && !mode.IsContainer()
|
||||||
|
}
|
||||||
|
|
||||||
// parseNetworkOpts converts --network advanced options to endpoint-specs, and combines
|
// parseNetworkOpts converts --network advanced options to endpoint-specs, and combines
|
||||||
// them with the old --network-alias and --links. If returns an error if conflicting options
|
// them with the old --network-alias and --links. If returns an error if conflicting options
|
||||||
// are found.
|
// are found.
|
||||||
@@ -752,7 +756,7 @@ func parseNetworkOpts(copts *containerOptions) (map[string]*network.EndpointSett
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, n := range copts.netMode.Value() {
|
for i, n := range copts.netMode.Value() {
|
||||||
if container.NetworkMode(n.Target).IsUserDefined() {
|
if isUserDefinedNetwork(container.NetworkMode(n.Target)) {
|
||||||
hasUserDefined = true
|
hasUserDefined = true
|
||||||
} else {
|
} else {
|
||||||
hasNonUserDefined = true
|
hasNonUserDefined = true
|
||||||
@@ -814,7 +818,7 @@ func applyContainerOptions(n *opts.NetworkAttachmentOpts, copts *containerOption
|
|||||||
}
|
}
|
||||||
// For a user-defined network, "--link" is an endpoint option, it creates an alias. But,
|
// For a user-defined network, "--link" is an endpoint option, it creates an alias. But,
|
||||||
// for the default bridge it defines a legacy-link.
|
// for the default bridge it defines a legacy-link.
|
||||||
if container.NetworkMode(n.Target).IsUserDefined() && copts.links.Len() > 0 {
|
if isUserDefinedNetwork(container.NetworkMode(n.Target)) && copts.links.Len() > 0 {
|
||||||
n.Links = slices.Clone(copts.links.GetSlice())
|
n.Links = slices.Clone(copts.links.GetSlice())
|
||||||
}
|
}
|
||||||
if copts.ipv4Address != nil {
|
if copts.ipv4Address != nil {
|
||||||
@@ -840,7 +844,7 @@ func parseNetworkAttachmentOpt(ep opts.NetworkAttachmentOpts) (*network.Endpoint
|
|||||||
if strings.TrimSpace(ep.Target) == "" {
|
if strings.TrimSpace(ep.Target) == "" {
|
||||||
return nil, errors.New("no name set for network")
|
return nil, errors.New("no name set for network")
|
||||||
}
|
}
|
||||||
if !container.NetworkMode(ep.Target).IsUserDefined() {
|
if !isUserDefinedNetwork(container.NetworkMode(ep.Target)) {
|
||||||
if len(ep.Aliases) > 0 {
|
if len(ep.Aliases) > 0 {
|
||||||
return nil, errors.New("network-scoped aliases are only supported for user-defined networks")
|
return nil, errors.New("network-scoped aliases are only supported for user-defined networks")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func loadScratchImage(t *testing.T, platform string) string {
|
|||||||
response, err := cli.ImageLoad(t.Context(), &archive)
|
response, err := cli.ImageLoad(t.Context(), &archive)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer response.Close()
|
defer response.Close()
|
||||||
require.NoError(t, logDockerResponse(common.Logger(t.Context()), response, false))
|
require.NoError(t, logDockerResponse(common.Logger(t.Context()), response, common.Logger(t.Context()).Debugf))
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_, _ = cli.ImageRemove(context.WithoutCancel(t.Context()), tag, mobyclient.ImageRemoveOptions{Force: true})
|
_, _ = cli.ImageRemove(context.WithoutCancel(t.Context()), tag, mobyclient.ImageRemoveOptions{Force: true})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"cmp"
|
||||||
"encoding/json/v2"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -26,10 +27,7 @@ type dockerMessage struct {
|
|||||||
Progress string `json:"progress"`
|
Progress string `json:"progress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func logDockerResponse(logger *logging.Logger, dockerResponse io.ReadCloser, isError bool) error {
|
func logDockerResponse(logger *logging.Logger, dockerResponse io.ReadCloser, log func(string, ...any)) error {
|
||||||
if dockerResponse == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer dockerResponse.Close()
|
defer dockerResponse.Close()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(dockerResponse)
|
scanner := bufio.NewScanner(dockerResponse)
|
||||||
@@ -38,41 +36,28 @@ func logDockerResponse(logger *logging.Logger, dockerResponse io.ReadCloser, isE
|
|||||||
line := scanner.Bytes()
|
line := scanner.Bytes()
|
||||||
var msg dockerMessage
|
var msg dockerMessage
|
||||||
if err := json.Unmarshal(line, &msg); err != nil {
|
if err := json.Unmarshal(line, &msg); err != nil {
|
||||||
writeLog(logger, false, "Unable to unmarshal line [%s] ==> %v", string(line), err)
|
logger.Debugf("Unable to unmarshal line [%s] ==> %v", string(line), err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.Error != "" {
|
if message := cmp.Or(msg.Error, msg.ErrorDetail.Message); message != "" {
|
||||||
writeLog(logger, isError, "%s", msg.Error)
|
log("%s", message)
|
||||||
return errors.New(msg.Error)
|
return errors.New(message)
|
||||||
}
|
|
||||||
|
|
||||||
if msg.ErrorDetail.Message != "" {
|
|
||||||
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
|
||||||
return errors.New(msg.ErrorDetail.Message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case msg.Status != "":
|
case msg.Status != "":
|
||||||
if msg.Progress != "" {
|
if msg.Progress != "" {
|
||||||
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
log("%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
||||||
} else {
|
} else {
|
||||||
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
|
log("%s :: %s\n", msg.Status, msg.ID)
|
||||||
}
|
}
|
||||||
case msg.Stream != "":
|
case msg.Stream != "":
|
||||||
writeLog(logger, isError, "%s", msg.Stream)
|
log("%s", msg.Stream)
|
||||||
default:
|
default:
|
||||||
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
logger.Debugf("Unable to handle line: %s", string(line))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanner.Err()
|
return scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeLog(logger *logging.Logger, isError bool, format string, args ...any) {
|
|
||||||
if isError {
|
|
||||||
logger.Errorf(format, args...)
|
|
||||||
} else {
|
|
||||||
logger.Debugf(format, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2025 The nektos/act Authors. All rights reserved.
|
// Copyright 2025 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
@@ -40,7 +40,9 @@ const (
|
|||||||
jobLabel = "com.gitea.runner.job."
|
jobLabel = "com.gitea.runner.job."
|
||||||
maxCreateBody = 8 << 20
|
maxCreateBody = 8 << 20
|
||||||
|
|
||||||
dockerProxyProbeTimeout = 5 * time.Second
|
dockerProxyProbeTimeout = 5 * time.Second
|
||||||
|
dockerProxyProbeWorkerTimeout = time.Minute
|
||||||
|
dockerProxyProbeTarget = "/gitea-runner-probe"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -48,7 +50,7 @@ var (
|
|||||||
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
var dockerProxyDirectories, dockerProxyProbes sync.Map
|
var dockerProxyProbes sync.Map
|
||||||
|
|
||||||
type dockerProxyDirectory struct {
|
type dockerProxyDirectory struct {
|
||||||
dir, daemonDir string
|
dir, daemonDir string
|
||||||
@@ -68,7 +70,7 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
|
|||||||
}
|
}
|
||||||
probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
|
probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cli, err := GetDockerClient(probeCtx)
|
cli, err := client.New(client.FromEnv) // negotiates on first request, so a cached probe costs no round trip
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -83,65 +85,69 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
|
|||||||
}
|
}
|
||||||
workdir, _ := os.Getwd()
|
workdir, _ := os.Getwd()
|
||||||
key := [3]string{cli.DaemonHost(), workdir, os.TempDir()}
|
key := [3]string{cli.DaemonHost(), workdir, os.TempDir()}
|
||||||
cached, _ := dockerProxyDirectories.Load(key)
|
probe, err := probeDockerProxyDirectory(probeCtx, cli, key, socket)
|
||||||
directory, seen := cached.(dockerProxyDirectory)
|
|
||||||
seen = seen && os.SameFile(directory.socket, socket) // daemon replacement invalidates cached paths
|
|
||||||
if !seen {
|
|
||||||
directory, seen, err = probeDockerProxyDirectory(probeCtx, cli, key, socket)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !seen {
|
if !probe.seen {
|
||||||
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
|
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
proxy, err := StartDockerProxy(daemonSocket, directory.dir, job)
|
proxy, err := StartDockerProxy(daemonSocket, probe.directory.dir, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dockerProxyDirectories.Delete(key)
|
dockerProxyProbes.CompareAndDelete(key, probe)
|
||||||
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
dockerProxyDirectories.Store(key, directory)
|
proxy.Socket = probe.directory.daemonDir + strings.TrimPrefix(proxy.Socket, probe.directory.dir)
|
||||||
proxy.Socket = directory.daemonDir + strings.TrimPrefix(proxy.Socket, directory.dir)
|
|
||||||
return proxy
|
return proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
func probeDockerProxyDirectory(ctx context.Context, cli client.APIClient, key [3]string, socket os.FileInfo) (dockerProxyDirectory, bool, error) {
|
func probeDockerProxyDirectory(ctx context.Context, cli client.APIClient, key [3]string, socket os.FileInfo) (*dockerProxyProbe, error) {
|
||||||
for {
|
for {
|
||||||
probe := &dockerProxyProbe{directory: dockerProxyDirectory{socket: socket}, done: make(chan struct{})}
|
probe := &dockerProxyProbe{directory: dockerProxyDirectory{socket: socket}, done: make(chan struct{})}
|
||||||
actual, loaded := dockerProxyProbes.LoadOrStore(key, probe)
|
actual, loaded := dockerProxyProbes.LoadOrStore(key, probe)
|
||||||
if loaded {
|
if loaded {
|
||||||
probe, _ = actual.(*dockerProxyProbe)
|
probe, _ = actual.(*dockerProxyProbe)
|
||||||
if !os.SameFile(probe.directory.socket, socket) {
|
if !os.SameFile(probe.directory.socket, socket) { // daemon replacement invalidates cached paths
|
||||||
dockerProxyProbes.CompareAndDelete(key, probe)
|
dockerProxyProbes.CompareAndDelete(key, probe)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return dockerProxyDirectory{}, false, ctx.Err()
|
|
||||||
case <-probe.done:
|
|
||||||
}
|
|
||||||
if ctx.Err() == nil && (errors.Is(probe.err, context.Canceled) || errors.Is(probe.err, context.DeadlineExceeded)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
probe.directory.dir, probe.directory.daemonDir = runnerContainerWorkdir(ctx, cli)
|
go func() { // outlives callers' deadlines so a slow daemon still gets its result cached
|
||||||
probe.seen = probe.directory.daemonDir != ""
|
defer cli.Close()
|
||||||
if !probe.seen {
|
ctx, cancel := context.WithTimeout(context.Background(), dockerProxyProbeWorkerTimeout)
|
||||||
if probe.directory.dir, probe.err = filepath.Abs(key[2]); probe.err == nil {
|
defer cancel()
|
||||||
probe.directory.daemonDir = probe.directory.dir
|
probe.directory.dir, probe.directory.daemonDir = runnerContainerWorkdir(ctx, cli)
|
||||||
probe.seen, probe.err = daemonSeesDir(ctx, cli, probe.directory.dir, probe.directory.daemonDir)
|
probe.seen = probe.directory.daemonDir != ""
|
||||||
|
if !probe.seen {
|
||||||
|
if probe.directory.dir, probe.err = filepath.Abs(key[2]); probe.err == nil {
|
||||||
|
probe.directory.daemonDir = probe.directory.dir
|
||||||
|
probe.seen, probe.err = daemonSeesDir(ctx, cli, probe.directory.dir, probe.directory.daemonDir)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if probe.seen {
|
||||||
dockerProxyProbes.CompareAndDelete(key, probe)
|
dockerProxyProbes.LoadOrStore(key, probe)
|
||||||
close(probe.done)
|
} else {
|
||||||
|
dockerProxyProbes.CompareAndDelete(key, probe)
|
||||||
|
}
|
||||||
|
close(probe.done)
|
||||||
|
removeStaleProbeContainers(cli)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
return probe.directory, probe.seen, probe.err
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return &dockerProxyProbe{}, ctx.Err()
|
||||||
|
case <-probe.done:
|
||||||
|
}
|
||||||
|
if ctx.Err() == nil && errors.Is(probe.err, context.DeadlineExceeded) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return probe, probe.err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +206,7 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
|
|||||||
created, err = cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
created, err = cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
||||||
Config: &container.Config{Image: image.ID, Cmd: []string{"true"}},
|
Config: &container.Config{Image: image.ID, Cmd: []string{"true"}},
|
||||||
HostConfig: &container.HostConfig{Mounts: []mount.Mount{
|
HostConfig: &container.HostConfig{Mounts: []mount.Mount{
|
||||||
{Type: mount.TypeBind, Source: daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}, // not the marker itself, podman creates a missing bind source where docker rejects it
|
{Type: mount.TypeBind, Source: daemonDir, Target: dockerProxyProbeTarget, ReadOnly: true}, // not the marker itself, podman creates a missing bind source where docker rejects it
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
if !cerrdefs.IsNotFound(err) {
|
if !cerrdefs.IsNotFound(err) {
|
||||||
@@ -213,7 +219,7 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
seen, err := containerSeesMarker(ctx, cli, dir, created.ID, "/gitea-runner-probe")
|
seen, err := containerSeesMarker(ctx, cli, dir, created.ID, dockerProxyProbeTarget)
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, removeErr := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
|
if _, removeErr := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
|
||||||
@@ -222,6 +228,36 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir str
|
|||||||
return seen, err
|
return seen, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeStaleProbeContainers removes probe containers left by abandoned creates once any probe in flight when listed has ended.
|
||||||
|
func removeStaleProbeContainers(cli client.APIClient) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), dockerProxyProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
listed, err := cli.ContainerList(ctx, client.ContainerListOptions{
|
||||||
|
All: true,
|
||||||
|
Filters: make(client.Filters).Add("status", string(container.StateCreated)).Add("volume", dockerProxyProbeTarget),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("listing stale docker proxy probe containers failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listed.Items = slices.DeleteFunc(listed.Items, func(stale container.Summary) bool {
|
||||||
|
return stale.State != container.StateCreated || !slices.ContainsFunc(stale.Mounts, func(mountPoint container.MountPoint) bool {
|
||||||
|
return mountPoint.Type == mount.TypeBind && !mountPoint.RW && mountPoint.Destination == dockerProxyProbeTarget
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if len(listed.Items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(dockerProxyProbeWorkerTimeout + dockerProxyProbeTimeout)
|
||||||
|
for _, stale := range listed.Items {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), dockerProxyProbeTimeout)
|
||||||
|
if err := (&containerReference{cli: cli, id: stale.ID}).remove()(ctx); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("removing stale docker proxy probe container failed: %v", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
|
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
|
||||||
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||||
info, err := os.Stat(daemonSocket)
|
info, err := os.Stat(daemonSocket)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
@@ -94,6 +94,8 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`[{"Id":"probe-image"}]`))
|
_, _ = w.Write([]byte(`[{"Id":"probe-image"}]`))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/containers/json"):
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
case strings.HasSuffix(r.URL.Path, "/json"):
|
case strings.HasSuffix(r.URL.Path, "/json"):
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
case strings.HasSuffix(r.URL.Path, "/archive"):
|
case strings.HasSuffix(r.URL.Path, "/archive"):
|
||||||
@@ -235,7 +237,8 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
t.Skip("automatic proxy discovery requires Linux")
|
t.Skip("automatic proxy discovery requires Linux")
|
||||||
}
|
}
|
||||||
t.Setenv("DOCKER_HOST", "unix://"+daemonSocket)
|
t.Setenv("DOCKER_HOST", "unix://"+daemonSocket)
|
||||||
t.Setenv("TMPDIR", shortTempDir(t))
|
tempDir := shortTempDir(t)
|
||||||
|
t.Setenv("TMPDIR", tempDir)
|
||||||
require.Nil(t, NewDockerProxy(t.Context(), "failed"))
|
require.Nil(t, NewDockerProxy(t.Context(), "failed"))
|
||||||
for _, job := range []string{"first", "second"} {
|
for _, job := range []string{"first", "second"} {
|
||||||
proxy := NewDockerProxy(t.Context(), job)
|
proxy := NewDockerProxy(t.Context(), job)
|
||||||
@@ -243,12 +246,10 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
require.NoError(t, proxy.Close(context.Background()))
|
require.NoError(t, proxy.Close(context.Background()))
|
||||||
}
|
}
|
||||||
assert.EqualValues(t, 2, probes.Load())
|
assert.EqualValues(t, 2, probes.Load())
|
||||||
dockerProxyDirectories.Range(func(key, value any) bool {
|
workdir, err := os.Getwd()
|
||||||
directory, _ := value.(dockerProxyDirectory)
|
require.NoError(t, err)
|
||||||
directory.socket, _ = os.Stat(t.TempDir())
|
cached, _ := dockerProxyProbes.Load([3]string{"unix://" + daemonSocket, workdir, tempDir})
|
||||||
dockerProxyDirectories.Store(key, directory)
|
cached.(*dockerProxyProbe).directory.socket, _ = os.Stat(t.TempDir())
|
||||||
return true
|
|
||||||
})
|
|
||||||
proxy := NewDockerProxy(t.Context(), "replaced daemon")
|
proxy := NewDockerProxy(t.Context(), "replaced daemon")
|
||||||
require.NotNil(t, proxy)
|
require.NotNil(t, proxy)
|
||||||
require.NoError(t, proxy.Close(context.Background()))
|
require.NoError(t, proxy.Close(context.Background()))
|
||||||
@@ -476,10 +477,27 @@ func BenchmarkDockerProxy(b *testing.B) {
|
|||||||
|
|
||||||
type probeClient struct {
|
type probeClient struct {
|
||||||
mobyclient.APIClient
|
mobyclient.APIClient
|
||||||
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
create func(context.Context, mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
||||||
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
||||||
stat func(string) error
|
stat func(string) error
|
||||||
mounts []container.MountPoint
|
mounts []container.MountPoint
|
||||||
|
containers []container.Summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) Info(_ctx context.Context, _opts mobyclient.InfoOptions) (mobyclient.SystemInfoResult, error) {
|
||||||
|
return mobyclient.SystemInfoResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerList(context.Context, mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
|
||||||
|
return mobyclient.ContainerListResult{Items: c.containers}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerKill(context.Context, string, mobyclient.ContainerKillOptions) (mobyclient.ContainerKillResult, error) {
|
||||||
|
return mobyclient.ContainerKillResult{}, cerrdefs.ErrConflict
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *probeClient) ContainerInspect(context.Context, string, mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
func (c *probeClient) ContainerInspect(context.Context, string, mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||||
@@ -490,8 +508,8 @@ func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (m
|
|||||||
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "removed-image"}, {ID: "probe-image"}}}, nil
|
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "removed-image"}, {ID: "probe-image"}}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
func (c *probeClient) ContainerCreate(ctx context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
return c.create(opts)
|
return c.create(ctx, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
@@ -518,7 +536,7 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
var calls, removals atomic.Int32
|
var calls, removals atomic.Int32
|
||||||
visible := false
|
visible := false
|
||||||
cli := &probeClient{
|
cli := &probeClient{
|
||||||
create: func(_opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
create: func(_ctx context.Context, _opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
calls.Add(1)
|
calls.Add(1)
|
||||||
<-release
|
<-release
|
||||||
return mobyclient.ContainerCreateResult{ID: "probe"}, createErr
|
return mobyclient.ContainerCreateResult{ID: "probe"}, createErr
|
||||||
@@ -538,23 +556,18 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
results := make(chan result, 8)
|
results := make(chan result, 8)
|
||||||
for range cap(results) {
|
for range cap(results) {
|
||||||
go func() {
|
go func() {
|
||||||
_, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
||||||
results <- result{seen, err}
|
results <- result{probe.seen, err}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
assert.EqualValues(t, 1, calls.Load())
|
assert.EqualValues(t, 1, calls.Load())
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
cancel()
|
|
||||||
_, seen, err := probeDockerProxyDirectory(ctx, cli, key, socket)
|
|
||||||
require.ErrorIs(t, err, context.Canceled)
|
|
||||||
assert.False(t, seen)
|
|
||||||
replacement, err := os.Stat(t.TempDir())
|
replacement, err := os.Stat(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
replaced := make(chan result, 1)
|
replaced := make(chan result, 1)
|
||||||
go func() {
|
go func() {
|
||||||
_, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, replacement)
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, replacement)
|
||||||
replaced <- result{seen, err}
|
replaced <- result{probe.seen, err}
|
||||||
}()
|
}()
|
||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
assert.EqualValues(t, 2, calls.Load())
|
assert.EqualValues(t, 2, calls.Load())
|
||||||
@@ -574,11 +587,11 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
assert.Zero(t, removals.Load())
|
assert.Zero(t, removals.Load())
|
||||||
}
|
}
|
||||||
createErr, visible = nil, true
|
createErr, visible = nil, true
|
||||||
directory, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, seen)
|
assert.True(t, probe.seen)
|
||||||
assert.Equal(t, dir, directory.dir)
|
assert.Equal(t, dir, probe.directory.dir)
|
||||||
assert.Equal(t, dir, directory.daemonDir)
|
assert.Equal(t, dir, probe.directory.daemonDir)
|
||||||
assert.EqualValues(t, 3, calls.Load())
|
assert.EqualValues(t, 3, calls.Load())
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -586,7 +599,7 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
t.Run("cancelled probe does not cancel another caller", func(t *testing.T) {
|
t.Run("cancelled caller leaves the probe to finish for the others", func(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
socket, err := os.Stat(t.TempDir())
|
socket, err := os.Stat(t.TempDir())
|
||||||
@@ -595,49 +608,105 @@ func TestDockerProxyDirectoryProbes(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
calls := 0
|
var calls, removals atomic.Int32
|
||||||
cli := &probeClient{
|
cli := &probeClient{
|
||||||
create: func(_opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
create: func(ctx context.Context, _opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
calls++
|
calls.Add(1)
|
||||||
if calls == 1 {
|
<-release
|
||||||
<-release
|
return mobyclient.ContainerCreateResult{ID: "probe"}, ctx.Err()
|
||||||
|
},
|
||||||
|
stat: func(_path string) error { return nil },
|
||||||
|
remove: func(_ctx context.Context, _id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
removals.Add(1)
|
||||||
|
return mobyclient.ContainerRemoveResult{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
leader, follower, late := make(chan result, 1), make(chan result, 1), make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
probe, err := probeDockerProxyDirectory(ctx, cli, key, socket)
|
||||||
|
leader <- result{probe.seen, err}
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
go func() {
|
||||||
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
||||||
|
follower <- result{probe.seen, err}
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
cancel()
|
||||||
|
leaderResult := <-leader
|
||||||
|
require.ErrorIs(t, leaderResult.err, context.Canceled)
|
||||||
|
assert.False(t, leaderResult.seen)
|
||||||
|
go func() {
|
||||||
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
||||||
|
late <- result{probe.seen, err}
|
||||||
|
}()
|
||||||
|
synctest.Wait()
|
||||||
|
close(release)
|
||||||
|
for _, results := range []chan result{follower, late} {
|
||||||
|
result := <-results
|
||||||
|
require.NoError(t, result.err)
|
||||||
|
assert.True(t, result.seen)
|
||||||
|
}
|
||||||
|
assert.EqualValues(t, 1, calls.Load())
|
||||||
|
assert.EqualValues(t, 1, removals.Load())
|
||||||
|
_, cached := dockerProxyProbes.Load(key)
|
||||||
|
assert.True(t, cached)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
t.Run("caller joining a probe that ran out of time retries", func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
socket, err := os.Stat(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
var calls atomic.Int32
|
||||||
|
cli := &probeClient{
|
||||||
|
create: func(ctx context.Context, _opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
<-ctx.Done()
|
||||||
return mobyclient.ContainerCreateResult{}, ctx.Err()
|
return mobyclient.ContainerCreateResult{}, ctx.Err()
|
||||||
} else {
|
|
||||||
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
|
|
||||||
}
|
}
|
||||||
|
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
|
||||||
},
|
},
|
||||||
stat: func(_path string) error { return nil },
|
stat: func(_path string) error { return nil },
|
||||||
remove: func(_ctx context.Context, _id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
remove: func(_ctx context.Context, _id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
return mobyclient.ContainerRemoveResult{}, nil
|
return mobyclient.ContainerRemoveResult{}, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
leader, follower := make(chan result, 1), make(chan result, 1)
|
probe, err := probeDockerProxyDirectory(t.Context(), cli, [3]string{"daemon", dir, dir}, socket)
|
||||||
go func() {
|
require.NoError(t, err)
|
||||||
_, seen, err := probeDockerProxyDirectory(ctx, cli, key, socket)
|
assert.True(t, probe.seen)
|
||||||
leader <- result{seen, err}
|
assert.EqualValues(t, 2, calls.Load())
|
||||||
}()
|
|
||||||
synctest.Wait()
|
|
||||||
go func() {
|
|
||||||
_, seen, err := probeDockerProxyDirectory(t.Context(), cli, key, socket)
|
|
||||||
follower <- result{seen, err}
|
|
||||||
}()
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 1, calls)
|
|
||||||
cancel()
|
|
||||||
close(release)
|
|
||||||
leaderResult, followerResult := <-leader, <-follower
|
|
||||||
require.ErrorIs(t, leaderResult.err, context.Canceled)
|
|
||||||
assert.False(t, leaderResult.seen)
|
|
||||||
require.NoError(t, followerResult.err)
|
|
||||||
assert.True(t, followerResult.seen)
|
|
||||||
assert.Equal(t, 2, calls)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoveStaleProbeContainers(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
probeMount := container.MountPoint{Type: mount.TypeBind, Destination: "/gitea-runner-probe"}
|
||||||
|
writableMount := container.MountPoint{Type: mount.TypeBind, Destination: "/gitea-runner-probe", RW: true}
|
||||||
|
var removed []string
|
||||||
|
cli := &probeClient{
|
||||||
|
containers: []container.Summary{
|
||||||
|
{ID: "abandoned", State: container.StateCreated, Mounts: []container.MountPoint{probeMount}},
|
||||||
|
{ID: "foreign", State: container.StateCreated, Mounts: []container.MountPoint{writableMount}},
|
||||||
|
{ID: "started", State: container.StateExited, Mounts: []container.MountPoint{probeMount}},
|
||||||
|
},
|
||||||
|
remove: func(_ctx context.Context, id string, _opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
removed = append(removed, id)
|
||||||
|
return mobyclient.ContainerRemoveResult{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
removeStaleProbeContainers(cli)
|
||||||
|
assert.Equal(t, []string{"abandoned"}, removed)
|
||||||
|
assert.GreaterOrEqual(t, time.Since(start), dockerProxyProbeWorkerTimeout+dockerProxyProbeTimeout)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestDaemonSeesDir(t *testing.T) {
|
func TestDaemonSeesDir(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
markers := make(map[string]bool)
|
markers := make(map[string]bool)
|
||||||
|
|
||||||
for _, testCase := range []struct {
|
for _, testCase := range []struct {
|
||||||
name string
|
name string
|
||||||
daemonDir string
|
daemonDir string
|
||||||
@@ -656,7 +725,7 @@ func TestDaemonSeesDir(t *testing.T) {
|
|||||||
removed := false
|
removed := false
|
||||||
var attemptedImages []string
|
var attemptedImages []string
|
||||||
cli := &probeClient{
|
cli := &probeClient{
|
||||||
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
create: func(_ctx context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts)
|
assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts)
|
||||||
attemptedImages = append(attemptedImages, opts.Config.Image)
|
attemptedImages = append(attemptedImages, opts.Config.Image)
|
||||||
if opts.Config.Image == "removed-image" {
|
if opts.Config.Image == "removed-image" {
|
||||||
@@ -692,6 +761,7 @@ func TestDaemonSeesDir(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"removed-image", "probe-image"}, attemptedImages)
|
assert.Equal(t, []string{"removed-image", "probe-image"}, attemptedImages)
|
||||||
assert.Equal(t, testCase.seen, seen)
|
assert.Equal(t, testCase.seen, seen)
|
||||||
assert.Equal(t, testCase.removed, removed)
|
assert.Equal(t, testCase.removed, removed)
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, entries)
|
assert.Empty(t, entries)
|
||||||
@@ -700,6 +770,9 @@ func TestDaemonSeesDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerContainerWorkdir(t *testing.T) {
|
func TestRunnerContainerWorkdir(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("a runner container is Linux")
|
||||||
|
}
|
||||||
workdir := t.TempDir()
|
workdir := t.TempDir()
|
||||||
t.Chdir(workdir)
|
t.Chdir(workdir)
|
||||||
volume := container.MountPoint{Type: mount.TypeVolume, Source: "/daemon/_data", Destination: filepath.Dir(workdir)}
|
volume := container.MountPoint{Type: mount.TypeVolume, Source: "/daemon/_data", Destination: filepath.Dir(workdir)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd || freebsd)
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
|
|
||||||
// NewDockerPullExecutor function to create a run executor for the container
|
// NewDockerPullExecutor function to create a run executor for the container
|
||||||
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return common.Executor(func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
logger.Debugf("docker pull %v", input.Image)
|
logger.Debugf("docker pull %v", input.Image)
|
||||||
|
|
||||||
@@ -61,11 +61,10 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
|||||||
// stream rather than on the call itself, so both have to be checked
|
// stream rather than on the call itself, so both have to be checked
|
||||||
pullOnce := func(opts client.ImagePullOptions) error {
|
pullOnce := func(opts client.ImagePullOptions) error {
|
||||||
reader, err := cli.ImagePull(ctx, imageRef, opts)
|
reader, err := cli.ImagePull(ctx, imageRef, opts)
|
||||||
streamErr := logDockerResponse(logger, reader, err != nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return streamErr
|
return logDockerResponse(logger, reader, logger.Debugf)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pullOnce(imagePullOptions)
|
err = pullOnce(imagePullOptions)
|
||||||
@@ -83,7 +82,7 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||||
}
|
}).Retry(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
||||||
|
|||||||
@@ -95,15 +95,16 @@ func TestIsPinnedImage(t *testing.T) {
|
|||||||
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
||||||
// whichever of the two shapes the daemon used.
|
// whichever of the two shapes the daemon used.
|
||||||
func TestLogDockerResponseError(t *testing.T) {
|
func TestLogDockerResponseError(t *testing.T) {
|
||||||
logger := logging.New(logtest.New())
|
logger, hook := logtest.NewLogger()
|
||||||
streamErr := func(line string) error {
|
streamErr := func(line string) error {
|
||||||
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), logger.Infof)
|
||||||
}
|
}
|
||||||
require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded")
|
require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded")
|
||||||
require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF")
|
require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF")
|
||||||
require.NoError(t, streamErr(`{"status":"Downloading"}`))
|
require.NoError(t, streamErr(`{"status":"Downloading"}`))
|
||||||
require.NoError(t, streamErr(`{"stream":"`+strings.Repeat("x", 70000)+`"}`))
|
require.NoError(t, streamErr(`{"stream":"`+strings.Repeat("x", 70000)+`"}`))
|
||||||
|
assert.Equal(t, logging.LevelInfo, hook.LastRecord().Level)
|
||||||
require.ErrorIs(t, logDockerResponse(logger, io.NopCloser(io.MultiReader(
|
require.ErrorIs(t, logDockerResponse(logger, io.NopCloser(io.MultiReader(
|
||||||
strings.NewReader(`{"status":"Downloading"}`+"\n"), iotest.ErrReader(io.ErrUnexpectedEOF),
|
strings.NewReader(`{"status":"Downloading"}`+"\n"), iotest.ErrReader(io.ErrUnexpectedEOF),
|
||||||
)), false), io.ErrUnexpectedEOF)
|
)), logger.Infof), io.ErrUnexpectedEOF)
|
||||||
}
|
}
|
||||||
+50
-29
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd || freebsd))
|
||||||
|
|
||||||
package container
|
package container
|
||||||
|
|
||||||
@@ -41,13 +41,6 @@ import (
|
|||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
|
||||||
// finish draining a container's output before returning, so that neither a
|
|
||||||
// cancellation (waitForCommand) nor a normal container exit (wait) truncates
|
|
||||||
// the tail of the log. It is a safety bound: in the common case the stream
|
|
||||||
// reaches EOF and the goroutine returns well before this elapses.
|
|
||||||
const drainGracePeriod = 2 * time.Second
|
|
||||||
|
|
||||||
// NewContainer creates a reference to a container
|
// NewContainer creates a reference to a container
|
||||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||||
// Resolved up front because the image pull runs before the container is created.
|
// Resolved up front because the image pull runs before the container is created.
|
||||||
@@ -366,6 +359,7 @@ type containerReference struct {
|
|||||||
id string
|
id string
|
||||||
input *NewContainerInput
|
input *NewContainerInput
|
||||||
pullPolicy string
|
pullPolicy string
|
||||||
|
daemonOS string
|
||||||
UID int
|
UID int
|
||||||
GID int
|
GID int
|
||||||
// attachDone is closed by the attach() streaming goroutine once it has
|
// attachDone is closed by the attach() streaming goroutine once it has
|
||||||
@@ -375,13 +369,18 @@ type containerReference struct {
|
|||||||
LinuxContainerEnvironmentExtensions
|
LinuxContainerEnvironmentExtensions
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) {
|
func GetDockerClient(ctx context.Context) (client.APIClient, error) {
|
||||||
|
cli, _, err := newDockerClient(ctx)
|
||||||
|
return cli, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDockerClient(ctx context.Context) (cli client.APIClient, daemonOS string, err error) {
|
||||||
dockerHost := os.Getenv("DOCKER_HOST")
|
dockerHost := os.Getenv("DOCKER_HOST")
|
||||||
|
|
||||||
if strings.HasPrefix(dockerHost, "ssh://") {
|
if strings.HasPrefix(dockerHost, "ssh://") {
|
||||||
var args []string
|
var args []string
|
||||||
if args, err = sshArgs(dockerHost); err != nil {
|
if args, err = sshArgs(dockerHost); err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
cli, err = client.New(
|
cli, err = client.New(
|
||||||
client.WithHost("http://docker.example.com"), // the dialer below ignores the address
|
client.WithHost("http://docker.example.com"), // the dialer below ignores the address
|
||||||
@@ -391,16 +390,17 @@ func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) {
|
|||||||
cli, err = client.New(client.FromEnv)
|
cli, err = client.New(client.FromEnv)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to docker daemon: %w", err)
|
return nil, "", fmt.Errorf("failed to connect to docker daemon: %w", err)
|
||||||
}
|
}
|
||||||
// Best-effort API version negotiation, matching the old client.NegotiateAPIVersion
|
// Best-effort API version negotiation, matching the old client.NegotiateAPIVersion
|
||||||
// behaviour. Ping failures here are non-fatal: the connection is exercised again on
|
// behaviour. Ping failures here are non-fatal: the connection is exercised again on
|
||||||
// the first real call, and the client falls back to the daemon's default API version.
|
// the first real call, and the client falls back to the daemon's default API version.
|
||||||
if _, err := cli.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true}); err != nil {
|
ping, err := cli.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true})
|
||||||
|
if err != nil {
|
||||||
common.Logger(ctx).Warnf("docker daemon ping during version negotiation failed, continuing: %v", err)
|
common.Logger(ctx).Warnf("docker daemon ping during version negotiation failed, continuing: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli, nil
|
return cli, ping.OSType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHostInfo(ctx context.Context) (system.Info, error) {
|
func GetHostInfo(ctx context.Context) (system.Info, error) {
|
||||||
@@ -440,16 +440,32 @@ func RunnerArch(ctx context.Context) string {
|
|||||||
return info.Architecture
|
return info.Architecture
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cr *containerReference) targetOS(ctx context.Context) string {
|
||||||
|
if cr.daemonOS == "" {
|
||||||
|
cr.daemonOS = "linux"
|
||||||
|
if cr.cli != nil {
|
||||||
|
if result, err := cr.cli.Info(ctx, client.InfoOptions{}); err == nil && result.Info.OSType != "" {
|
||||||
|
cr.daemonOS = result.Info.OSType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cr.daemonOS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cr *containerReference) markExecutable(ctx context.Context) bool {
|
||||||
|
return hostOS == "windows" && cr.targetOS(ctx) == "linux"
|
||||||
|
}
|
||||||
|
|
||||||
func (cr *containerReference) connect() common.Executor {
|
func (cr *containerReference) connect() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.cli != nil {
|
if cr.cli != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, daemonOS, err := newDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cr.cli = cli
|
cr.cli, cr.daemonOS = cli, daemonOS
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,6 +475,7 @@ func (cr *containerReference) Close() common.Executor {
|
|||||||
if cr.cli != nil {
|
if cr.cli != nil {
|
||||||
err := cr.cli.Close()
|
err := cr.cli.Close()
|
||||||
cr.cli = nil
|
cr.cli = nil
|
||||||
|
cr.daemonOS = ""
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to close client: %w", err)
|
return fmt.Errorf("failed to close client: %w", err)
|
||||||
}
|
}
|
||||||
@@ -603,7 +620,8 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
copts.privileged = false
|
copts.privileged = false
|
||||||
}
|
}
|
||||||
|
|
||||||
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
serverOS := cr.targetOS(ctx)
|
||||||
|
containerConfig, err := parse(flags, copts, serverOS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
return fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
@@ -618,7 +636,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
|
|
||||||
// For Gitea, forcing --privileged off is not enough, other options reach the host too
|
// For Gitea, forcing --privileged off is not enough, other options reach the host too
|
||||||
if !hostConfig.Privileged {
|
if !hostConfig.Privileged {
|
||||||
trusted, err := parseOptionsHostConfig(cr.input.RunnerOptions)
|
trusted, err := parseOptionsHostConfig(cr.input.RunnerOptions, serverOS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -731,9 +749,7 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
cr.sanitizeConfig(ctx, hostConfig)
|
cr.sanitizeConfig(ctx, hostConfig)
|
||||||
|
|
||||||
var networkingConfig *network.NetworkingConfig
|
var networkingConfig *network.NetworkingConfig
|
||||||
n := hostConfig.NetworkMode
|
if isUserDefinedNetwork(hostConfig.NetworkMode) && len(input.NetworkAliases) > 0 {
|
||||||
// IsUserDefined and IsHost are broken on windows
|
|
||||||
if n.IsUserDefined() && n != "host" && len(input.NetworkAliases) > 0 {
|
|
||||||
networkingConfig = &network.NetworkingConfig{
|
networkingConfig = &network.NetworkingConfig{
|
||||||
EndpointsConfig: map[string]*network.EndpointSettings{
|
EndpointsConfig: map[string]*network.EndpointSettings{
|
||||||
input.NetworkMode: {Aliases: input.NetworkAliases},
|
input.NetworkMode: {Aliases: input.NetworkAliases},
|
||||||
@@ -838,6 +854,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Close()
|
defer resp.Close()
|
||||||
|
defer context.AfterFunc(ctx, resp.Close)()
|
||||||
|
|
||||||
sid, err := resp.Reader.ReadString('\n')
|
sid, err := resp.Reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -883,7 +900,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debugf("Failed to drain command output after cancellation: %v", err)
|
logger.Debugf("Failed to drain command output after cancellation: %v", err)
|
||||||
}
|
}
|
||||||
case <-time.After(drainGracePeriod):
|
case <-time.After(DrainGracePeriod):
|
||||||
logger.Warn("Timed out draining command output after cancellation")
|
logger.Warn("Timed out draining command output after cancellation")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,11 +932,8 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
|||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
extractionRoot := "/"
|
extractionRoot := cr.extractionRoot(dstPath)
|
||||||
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
|
if err := writeDirTar(ctx, tarFile, path.Join("/", strings.TrimPrefix(dstPath, extractionRoot)), srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID, cr.markExecutable(ctx)); err != nil {
|
||||||
extractionRoot = actPath // keeps tar entries from crossing the image's /var/run symlink
|
|
||||||
}
|
|
||||||
if err := writeDirTar(ctx, tarFile, path.Join("/", strings.TrimPrefix(dstPath, extractionRoot)), srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,6 +945,13 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cr *containerReference) extractionRoot(dstPath string) string {
|
||||||
|
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
|
||||||
|
return actPath // keeps tar entries from crossing the image's /var/run symlink
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
@@ -1023,7 +1044,7 @@ func (cr *containerReference) wait() common.Executor {
|
|||||||
if cr.attachDone != nil {
|
if cr.attachDone != nil {
|
||||||
select {
|
select {
|
||||||
case <-cr.attachDone:
|
case <-cr.attachDone:
|
||||||
case <-time.After(drainGracePeriod):
|
case <-time.After(DrainGracePeriod):
|
||||||
logger.Warn("Timed out draining container output")
|
logger.Warn("Timed out draining container output")
|
||||||
}
|
}
|
||||||
cr.attachDone = nil
|
cr.attachDone = nil
|
||||||
@@ -1090,12 +1111,12 @@ func resetOption[T any](logger *logging.Logger, option string, field *T, trusted
|
|||||||
|
|
||||||
// parseOptionsHostConfig parses one options string on its own, to see what it alone asks for.
|
// parseOptionsHostConfig parses one options string on its own, to see what it alone asks for.
|
||||||
// Even "" goes through the parser, or its empty slices and maps would differ from a real parse.
|
// Even "" goes through the parser, or its empty slices and maps would differ from a real parse.
|
||||||
func parseOptionsHostConfig(options string) (*container.HostConfig, error) {
|
func parseOptionsHostConfig(options, serverOS string) (*container.HostConfig, error) {
|
||||||
flags, copts, _, err := parseContainerOptions(options)
|
flags, copts, _, err := parseContainerOptions(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
containerConfig, err := parse(flags, copts, runtime.GOOS)
|
containerConfig, err := parse(flags, copts, serverOS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
return nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common/logging/logtest"
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
|
"github.com/moby/moby/api/types/system"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
@@ -65,6 +67,14 @@ func TestDockerEnvironmentUsesContainerPath(t *testing.T) {
|
|||||||
type mockDockerClient struct {
|
type mockDockerClient struct {
|
||||||
mobyclient.APIClient
|
mobyclient.APIClient
|
||||||
mock.Mock
|
mock.Mock
|
||||||
|
info system.Info
|
||||||
|
infoErr error
|
||||||
|
infoCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) Info(_ctx context.Context, _opts mobyclient.InfoOptions) (mobyclient.SystemInfoResult, error) {
|
||||||
|
m.infoCalls++
|
||||||
|
return mobyclient.SystemInfoResult{Info: m.info}, m.infoErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ServerVersion(ctx context.Context, opts mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) {
|
func (m *mockDockerClient) ServerVersion(ctx context.Context, opts mobyclient.ServerVersionOptions) (mobyclient.ServerVersionResult, error) {
|
||||||
@@ -112,6 +122,11 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, optio
|
|||||||
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, id string, options mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
||||||
|
args := m.Called(ctx, id, options)
|
||||||
|
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
||||||
@@ -341,6 +356,21 @@ func TestDockerAttachStreamClosesWithItsContext(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDockerReadIDStreamClosesWithItsContext(t *testing.T) {
|
||||||
|
conn, peer := net.Pipe()
|
||||||
|
t.Cleanup(func() { _ = peer.Close() })
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ExecCreate", mock.Anything, "123", mock.AnythingOfType("client.ExecCreateOptions")).
|
||||||
|
Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||||
|
client.On("ExecAttach", mock.Anything, "id", mobyclient.ExecAttachOptions{}).
|
||||||
|
Return(mobyclient.ExecAttachResult{HijackedResponse: mobyclient.NewHijackedResponse(conn, "")}, nil)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
require.NoError(t, (&containerReference{id: "123", cli: client}).tryReadID("-u", func(int) { t.Error("read an ID after cancellation") })(ctx))
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDockerWaitFailure(t *testing.T) {
|
func TestDockerWaitFailure(t *testing.T) {
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
statusCh := make(chan container.WaitResponse, 1)
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
@@ -530,6 +560,9 @@ func TestRejectsMissingContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
||||||
|
source := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
|
||||||
|
|
||||||
for _, testcase := range []struct {
|
for _, testcase := range []struct {
|
||||||
name, destination, extractionRoot, entry string
|
name, destination, extractionRoot, entry string
|
||||||
directory bool
|
directory bool
|
||||||
@@ -557,8 +590,6 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
|||||||
|
|
||||||
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
if testcase.directory {
|
if testcase.directory {
|
||||||
source := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
|
|
||||||
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
|
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
|
||||||
} else {
|
} else {
|
||||||
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
@@ -689,11 +720,11 @@ func TestMergeContainerConfigsDropsVolumeDriversFromWorkflows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
|
func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
|
||||||
hostFile := filepath.Join(t.TempDir(), "host.env")
|
hostFile := filepath.Join(t.TempDir(), "host file.env")
|
||||||
require.NoError(t, os.WriteFile(hostFile, []byte("STOLEN=from-the-host\n"), 0o600))
|
require.NoError(t, os.WriteFile(hostFile, []byte("STOLEN=from-the-host\n"), 0o600))
|
||||||
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
||||||
|
|
||||||
for _, option := range []string{"--env-file " + hostFile, "--label-file " + hostFile} {
|
for _, option := range []string{shellquote.Join("--env-file", hostFile), shellquote.Join("--label-file", hostFile)} {
|
||||||
logger := logging.New(logtest.New())
|
logger := logging.New(logtest.New())
|
||||||
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
|
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
|
||||||
|
|
||||||
@@ -933,6 +964,7 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
|||||||
"/etc/hosts": "",
|
"/etc/hosts": "",
|
||||||
"/etc/hostname": "",
|
"/etc/hostname": "",
|
||||||
}, info.Mounts)
|
}, info.Mounts)
|
||||||
|
assert.Equal(t, "/var/lib/docker/volumes/job/_data/subdir", info.DaemonPath("/workspace/owner/repo/subdir"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +1012,7 @@ func TestDockerCreateKeepsInputEntrypoint(t *testing.T) {
|
|||||||
RunnerOptions: "--entrypoint=runner-entrypoint",
|
RunnerOptions: "--entrypoint=runner-entrypoint",
|
||||||
WorkflowOptions: "--entrypoint=" + optionEntrypoint + " --init",
|
WorkflowOptions: "--entrypoint=" + optionEntrypoint + " --init",
|
||||||
},
|
},
|
||||||
cli: &probeClient{create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
cli: &probeClient{create: func(_ctx context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
if len(entrypoint) > 0 {
|
if len(entrypoint) > 0 {
|
||||||
assert.Equal(t, entrypoint, opts.Config.Entrypoint)
|
assert.Equal(t, entrypoint, opts.Config.Entrypoint)
|
||||||
} else {
|
} else {
|
||||||
@@ -998,6 +1030,39 @@ func TestDockerCreateKeepsInputEntrypoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDockerCreateNetworkAliases(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
networkMode string
|
||||||
|
wantAliases bool
|
||||||
|
}{
|
||||||
|
{"job-network", true},
|
||||||
|
{"host", false},
|
||||||
|
{"bridge", false},
|
||||||
|
{"default", false},
|
||||||
|
{"none", false},
|
||||||
|
{"container:x", false},
|
||||||
|
} {
|
||||||
|
t.Run(test.networkMode, func(t *testing.T) {
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{NetworkMode: test.networkMode, NetworkAliases: []string{"service"}},
|
||||||
|
cli: &probeClient{create: func(_ctx context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
assert.Equal(t, container.NetworkMode(test.networkMode), opts.HostConfig.NetworkMode)
|
||||||
|
if test.wantAliases {
|
||||||
|
assert.Equal(t, &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{
|
||||||
|
"job-network": {Aliases: []string{"service"}},
|
||||||
|
}}, opts.NetworkingConfig)
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, opts.NetworkingConfig)
|
||||||
|
}
|
||||||
|
return mobyclient.ContainerCreateResult{ID: "created"}, nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
require.NoError(t, cr.create(nil, nil)(t.Context()))
|
||||||
|
assert.Equal(t, "created", cr.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
||||||
logger := logging.New(logtest.New())
|
logger := logging.New(logtest.New())
|
||||||
cr := &containerReference{input: &NewContainerInput{
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
@@ -1019,3 +1084,93 @@ func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
|||||||
require.ErrorContains(t, err, "cannot connect to the Docker daemon")
|
require.ErrorContains(t, err, "cannot connect to the Docker daemon")
|
||||||
assert.False(t, supported)
|
assert.False(t, supported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainerOptionsUseDaemonOS(t *testing.T) {
|
||||||
|
for _, testcase := range []struct {
|
||||||
|
name string
|
||||||
|
serverOS string
|
||||||
|
infoErr error
|
||||||
|
device string
|
||||||
|
expected container.DeviceMapping
|
||||||
|
}{
|
||||||
|
{name: "Linux daemon", serverOS: "linux", device: "/dev/kvm:/dev/kvm:rwm", expected: container.DeviceMapping{PathOnHost: "/dev/kvm", PathInContainer: "/dev/kvm", CgroupPermissions: "rwm"}},
|
||||||
|
{name: "unknown daemon", device: "/dev/kvm:/dev/kvm:rwm", expected: container.DeviceMapping{PathOnHost: "/dev/kvm", PathInContainer: "/dev/kvm", CgroupPermissions: "rwm"}},
|
||||||
|
{name: "unavailable daemon info", infoErr: errors.New("unavailable"), device: "/dev/kvm:/dev/kvm:rwm", expected: container.DeviceMapping{PathOnHost: "/dev/kvm", PathInContainer: "/dev/kvm", CgroupPermissions: "rwm"}},
|
||||||
|
{name: "Windows daemon", serverOS: "windows", device: "class/01234567-89ab-cdef-0123-456789abcdef", expected: container.DeviceMapping{PathOnHost: "class/01234567-89ab-cdef-0123-456789abcdef"}},
|
||||||
|
} {
|
||||||
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
|
cli := &mockDockerClient{info: system.Info{OSType: testcase.serverOS}, infoErr: testcase.infoErr}
|
||||||
|
cr := &containerReference{cli: cli, input: &NewContainerInput{RunnerOptions: "--device=" + testcase.device, WorkflowOptions: "--device=" + testcase.device, NetworkMode: "bridge"}}
|
||||||
|
for range 2 {
|
||||||
|
hostConfig := &container.HostConfig{}
|
||||||
|
require.NoError(t, cr.mergeContainerConfigs(t.Context(), &container.Config{}, hostConfig))
|
||||||
|
assert.Equal(t, []container.DeviceMapping{testcase.expected}, hostConfig.Devices)
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, cli.infoCalls)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectTakesDaemonOSFromPing(t *testing.T) {
|
||||||
|
var infos atomic.Int32
|
||||||
|
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
writer.Header().Set("API-Version", "1.47")
|
||||||
|
writer.Header().Set("OSType", "windows")
|
||||||
|
if strings.HasSuffix(request.URL.Path, "/info") {
|
||||||
|
infos.Add(1)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(daemon.Close)
|
||||||
|
t.Setenv("DOCKER_HOST", daemon.URL)
|
||||||
|
cr := &containerReference{input: &NewContainerInput{}}
|
||||||
|
require.NoError(t, cr.connect()(t.Context()))
|
||||||
|
assert.Equal(t, "windows", cr.targetOS(t.Context()))
|
||||||
|
assert.Zero(t, infos.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerCopyModes(t *testing.T) {
|
||||||
|
source := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(source, "run.sh"), []byte("#!/bin/sh\n"), 0o666))
|
||||||
|
require.NoError(t, os.Chmod(filepath.Join(source, "run.sh"), 0o666))
|
||||||
|
|
||||||
|
for _, testcase := range []struct {
|
||||||
|
hostOS, serverOS string
|
||||||
|
expected int64
|
||||||
|
}{
|
||||||
|
{"windows", "linux", 0o755},
|
||||||
|
{"windows", "windows", 0o666},
|
||||||
|
{"linux", "linux", 0o666},
|
||||||
|
} {
|
||||||
|
t.Run(testcase.hostOS+"-"+testcase.serverOS, func(t *testing.T) {
|
||||||
|
file := &FileEntry{Name: "run.sh", Mode: 0o600, Body: "#!/bin/sh\n"}
|
||||||
|
original := hostOS
|
||||||
|
hostOS = testcase.hostOS
|
||||||
|
t.Cleanup(func() { hostOS = original })
|
||||||
|
cli := &mockDockerClient{info: system.Info{OSType: testcase.serverOS}}
|
||||||
|
var modes []int64
|
||||||
|
cli.On("CopyToContainer", mock.Anything, "job", mock.Anything).
|
||||||
|
Run(func(args mock.Arguments) {
|
||||||
|
reader := tar.NewReader(args.Get(2).(mobyclient.CopyToContainerOptions).Content)
|
||||||
|
header, err := reader.Next()
|
||||||
|
require.NoError(t, err)
|
||||||
|
modes = append(modes, header.Mode)
|
||||||
|
assert.Equal(t, "run.sh", filepath.Base(header.Name))
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, file.Body, string(content))
|
||||||
|
_, err = reader.Next()
|
||||||
|
require.ErrorIs(t, err, io.EOF)
|
||||||
|
}).Return(mobyclient.CopyToContainerResult{}, nil).Twice()
|
||||||
|
cr := &containerReference{cli: cli, id: "job", input: &NewContainerInput{}}
|
||||||
|
require.NoError(t, cr.copyContent("/workspace", file)(t.Context()))
|
||||||
|
require.NoError(t, cr.copyDir("/workspace", source+string(filepath.Separator), false, true)(t.Context()))
|
||||||
|
assert.Equal(t, []int64{0o600, testcase.expected}, modes)
|
||||||
|
if testcase.hostOS == "windows" {
|
||||||
|
assert.Equal(t, 1, cli.infoCalls)
|
||||||
|
} else {
|
||||||
|
assert.Zero(t, cli.infoCalls)
|
||||||
|
}
|
||||||
|
cli.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 50 of 216 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user