mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 14:55:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81add27459 | ||
|
|
ace8653105 | ||
|
|
95daa50d9b | ||
|
|
1952e5cdb6 | ||
|
|
7ad12038d1 | ||
|
|
98816a605c | ||
|
|
e0d00e543d | ||
|
|
ae9ce10f71 | ||
|
|
3962d0d970 | ||
|
|
d83fa77de4 | ||
|
|
58ed5921f3 | ||
|
|
00ff71a879 | ||
|
|
80db5f61ac | ||
|
|
5310d8119a | ||
|
|
b9c6305dee | ||
|
|
699a97fc8c | ||
|
|
19afebc53f | ||
|
|
2cc3000369 | ||
|
|
3d116eb0c2 | ||
|
|
e0ce6776c7 | ||
|
|
69d5c22300 | ||
|
|
54978255f5 | ||
|
|
498282caaa | ||
|
|
2ed8cdb76e | ||
|
|
ff9965e940 | ||
|
|
ba4d3c5b4f |
No files matched your search
@@ -74,10 +74,10 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||||
@@ -103,7 +103,7 @@ jobs:
|
|||||||
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ jobs:
|
|||||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||||
- name: pre-pull test images
|
- name: pre-pull test images
|
||||||
env:
|
env:
|
||||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
|
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
|
||||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:62ff2089abf5a9ed33bd232895bef5e22f7bb4b200675cec49a5ebc48e3d4ac8 # 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
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
@@ -53,6 +53,8 @@ jobs:
|
|||||||
# after `make test` so the images it needs are already present on the host daemon.
|
# after `make test` so the images it needs are already present on the host daemon.
|
||||||
- name: test against dind image
|
- name: test against dind image
|
||||||
run: make test-dind
|
run: make test-dind
|
||||||
|
- name: test against podman
|
||||||
|
run: make test-dind TARGET=podman
|
||||||
- name: coverage report
|
- name: coverage report
|
||||||
run: |
|
run: |
|
||||||
make coverage-report
|
make coverage-report
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ depending on the prefix:
|
|||||||
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
|
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
|
||||||
decodes exactly those two when folding a location into an annotation.
|
decodes exactly those two when folding a location into an annotation.
|
||||||
|
|
||||||
|
## Built-in actions
|
||||||
|
|
||||||
|
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
|
||||||
|
`act/runner/step_action_builtin.go`.
|
||||||
|
|
||||||
## End-to-end compatibility tests
|
## 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.
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ COPY scripts/s6 /etc/s6
|
|||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|
||||||
|
ENV TINI_SUBREAPER=1
|
||||||
|
|
||||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||||
|
|
||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
@@ -56,6 +58,7 @@ VOLUME /data
|
|||||||
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
|
RUN mkdir -p /data && chown -R rootless:rootless /etc/s6 /data
|
||||||
|
|
||||||
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
|
ENV DOCKER_HOST=unix:///run/user/1000/docker.sock
|
||||||
|
ENV TINI_SUBREAPER=1
|
||||||
|
|
||||||
USER rootless
|
USER rootless
|
||||||
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
|
|||||||
XGO_VERSION := go-1.27.x
|
XGO_VERSION := go-1.27.x
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected] # renovate: datasource=go
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected] # renovate: datasource=go
|
||||||
|
|
||||||
|
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
|
||||||
|
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # renovate: datasource=docker
|
||||||
|
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||||
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
|
|
||||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||||
WINDOWS_ARCHS ?= windows/amd64
|
WINDOWS_ARCHS ?= windows/amd64
|
||||||
@@ -18,8 +23,8 @@ DOCKER_TAG ?= nightly
|
|||||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].1 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].2 # renovate: datasource=go
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.7.0 # renovate: datasource=go
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go
|
||||||
|
|
||||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
@@ -96,13 +101,8 @@ go-check:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: fmt-check
|
.PHONY: fmt-check
|
||||||
fmt-check: fmt
|
fmt-check: ## check the Go code formatting
|
||||||
@diff=$$(git diff --color=always -- '*.go'); \
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make fmt' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: deps-tools
|
.PHONY: deps-tools
|
||||||
deps-tools: ## install tool dependencies
|
deps-tools: ## install tool dependencies
|
||||||
@@ -149,13 +149,8 @@ tidy: ## run go mod tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: tidy-check
|
.PHONY: tidy-check
|
||||||
tidy-check: tidy
|
tidy-check: ## check that go.mod and go.sum are tidy
|
||||||
@diff=$$(git diff --color=always -- go.mod go.sum); \
|
$(GO) mod tidy -diff
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make tidy' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## test everything (integration tests self-skip without docker/network)
|
test: ## test everything (integration tests self-skip without docker/network)
|
||||||
@@ -168,12 +163,9 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
|||||||
@echo "Wrote .tmp/coverage.md"
|
@echo "Wrote .tmp/coverage.md"
|
||||||
|
|
||||||
.PHONY: test-dind
|
.PHONY: test-dind
|
||||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
test-dind: ## run the daemon-facing tests against the built dind image or podman (TARGET=dind|dind-rootless|podman)
|
||||||
@./scripts/test-dind.sh $(TARGET)
|
@PODMAN_TEST_IMAGE=$(PODMAN_TEST_IMAGE) ./scripts/test-dind.sh $(TARGET)
|
||||||
|
|
||||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2 # renovate: datasource=docker
|
|
||||||
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
|
||||||
E2E_CONCURRENCY ?= 8
|
E2E_CONCURRENCY ?= 8
|
||||||
|
|
||||||
.PHONY: test-e2e
|
.PHONY: test-e2e
|
||||||
|
|||||||
@@ -268,14 +268,14 @@ Unlike GitHub, a job whose steps run on the host (a `host` label without `contai
|
|||||||
|
|
||||||
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
|
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
|
||||||
|
|
||||||
A container a job starts through the Docker socket cannot bind-mount the workspace by the job's own path, the daemon does not have it. `GITEA_DOCKER_WORKSPACE` holds the path the daemon sees. Use it as the prefix of workspace binds, with `.` as the fallback for local use, here in a `docker-compose.yaml`:
|
Containers a job starts through the Docker socket can bind-mount the workspace by the job's own path, as on a host, for example `docker run -v "$PWD:/src"`. Without the Docker proxy, for example with a remote `DOCKER_HOST`, use `GITEA_DOCKER_WORKSPACE`, the path the daemon sees, as the prefix of workspace binds, with `.` as the fallback for local use:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
||||||
```
|
```
|
||||||
|
|
||||||
Containers, networks and volumes a job creates through the socket are removed when the job ends. Both apply to jobs in containers, on the host `.` already works.
|
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. The proxy removes containers, networks and volumes after post steps and the completed hook. Named volumes created through it are job-scoped. To retain resources, mount the daemon socket explicitly in `container.options`. Host jobs use their existing Docker access, where `.` works.
|
||||||
|
|
||||||
#### Proxy
|
#### Proxy
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@ The runner uses them for its own requests and gives them to every job, in lower
|
|||||||
|
|
||||||
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
These hosts are added to `no_proxy` for jobs, so they are always reached directly:
|
||||||
|
|
||||||
- the cache server
|
- the built-in cache server, whose address is assigned at startup
|
||||||
- `localhost`, `127.0.0.1` and `::1`
|
- `localhost`, `127.0.0.1` and `::1`
|
||||||
- the job's service containers
|
- the job's service containers
|
||||||
- the Docker daemon, when it is reached over `tcp://`
|
- the Docker daemon, when it is reached over `tcp://`
|
||||||
@@ -306,63 +306,68 @@ Dockerfile actions are built with these variables as build arguments, so their `
|
|||||||
|
|
||||||
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment.
|
||||||
|
|
||||||
|
#### Built-in actions (`uses: builtin:<name>`)
|
||||||
|
|
||||||
|
Use `uses: builtin:checkout` to check out a repository without downloading `actions/checkout` or needing Node in the job image, see **[docs/builtin-actions.md](docs/builtin-actions.md)**.
|
||||||
|
|
||||||
#### Caching (`actions/cache`)
|
#### Caching (`actions/cache`)
|
||||||
|
|
||||||
Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default.
|
Each runner starts its own cache server, so runners do not share cached entries. A runner in a container on the jobs' Docker daemon attaches itself to each job network, so jobs reach its cache without extra setup.
|
||||||
|
|
||||||
**Eviction**
|
**Sharing a cache between runners**
|
||||||
|
|
||||||
An entry nothing has read or written for `retention` is removed, and a repository past `repo_size_limit` loses its least recently accessed entries until it fits; `size_limit` caps the whole cache the same way. Age alone never retires an entry still in use, and whatever these allow, the cache keeps free space above `health_check.min_free_disk_space_mb` when health checks are enabled.
|
Run one `gitea-runner cache-server` and point every runner at it, using the same secret everywhere, for example from `openssl rand -hex 32`:
|
||||||
|
|
||||||
These apply where the cache server runs, so on a shared server they belong in *its* config, not the runners'. See `retention`, `repo_size_limit`, `size_limit` and `sweep_interval` in [config.example.yaml](internal/pkg/config/config.example.yaml) for units and defaults.
|
```yaml
|
||||||
|
# cache-server.yaml
|
||||||
|
cache:
|
||||||
|
dir: /data/actcache
|
||||||
|
port: 8088
|
||||||
|
external_secret: "<secret>" # or external_secret_file: /path/to/secret
|
||||||
|
```
|
||||||
|
|
||||||
**Cache service v2**
|
```bash
|
||||||
|
gitea-runner -c cache-server.yaml cache-server
|
||||||
|
```
|
||||||
|
|
||||||
`actions/cache@v4.2` and later can use the *cache service v2* API. The runner serves it from the same store as v1, on by default, and it works with `external_server`. Turn it off with:
|
```yaml
|
||||||
|
# each runner's config
|
||||||
|
cache:
|
||||||
|
external_server: "http://<cache-server-host>:8088/"
|
||||||
|
external_secret: "<secret>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--dir`, `--host` and `--port` override the matching `cache` keys. Eviction settings take effect on the cache server, not on the runners.
|
||||||
|
|
||||||
|
Runners can also share one `cache.dir` on a file system with working file locks, at the cost of slower cache requests.
|
||||||
|
|
||||||
|
**Storing the cache in an S3-compatible bucket**
|
||||||
|
|
||||||
|
The cache can also be stored in an S3-compatible bucket such as AWS S3, MinIO or RustFS, with no cache server. Runners with the same settings use the same cache:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cache:
|
cache:
|
||||||
v2: false
|
s3:
|
||||||
|
endpoint: "http://minio:9000"
|
||||||
|
region: "us-east-1"
|
||||||
|
bucket: "runner-cache"
|
||||||
|
prefix: "gitea-runner-cache"
|
||||||
|
path_style: true
|
||||||
|
access_key_file: "/run/secrets/cache-s3-access-key"
|
||||||
|
secret_key_file: "/run/secrets/cache-s3-secret-key"
|
||||||
```
|
```
|
||||||
|
|
||||||
Those actions refuse any host they do not take for GitHub. Rather than misreport the server URL, the runner edits that check out of the action's own bundle on its way into the job, undone whenever the action is downloaded again. A bundle it does not recognise is left alone and keeps to v1. The same edit lets the stock `actions/upload-artifact` and `actions/download-artifact` work from `v4.4.0` on, without the `gitea-upload-artifact` fork, so it is made whatever `v2` says: that setting only governs the API the runner advertises. Set `runner.patch_actions: false` to leave every bundle exactly as shipped, an escape hatch for an action the edit breaks. The artifact actions then refuse again and the cache client keeps to v1.
|
A cache saved on one runner can be restored on the others within 30 seconds. The eviction settings below do not apply to the bucket, so add lifecycle rules to it that expire old caches and abort incomplete multipart uploads.
|
||||||
|
|
||||||
**Shared cache across multiple runners**
|
**Eviction**
|
||||||
|
|
||||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
Entries not read or written within `retention` (default `168h`) are removed. A repository over `repo_size_limit` (default `10GB`) loses its least recently used entries, and `size_limit` (off by default) caps the whole cache the same way. Entries in use are never removed. The cache also keeps 1024 MiB free on its volume, or `health_check.min_free_disk_space_mb` when health checks are enabled. See [config.example.yaml](internal/pkg/config/config.example.yaml) for all options.
|
||||||
|
|
||||||
1. Create a config file for the cache server host:
|
**Cache service v2**
|
||||||
|
|
||||||
```yaml
|
`actions/cache` v3.4.0, v4.2.0 and later use the cache service v2 API, which the runner serves by default. These actions fall back to v1 on hosts they do not recognize as GitHub, so the runner removes that check from them while a job runs. This also lets the stock `actions/upload-artifact` v4.4.0 and `actions/download-artifact` v4.1.8 and later work without the `gitea-upload-artifact` fork. With `runner.patch_actions: false`, the cache stays on v1 and the artifact actions fail.
|
||||||
cache:
|
|
||||||
dir: /data/actcache
|
|
||||||
port: 8088
|
|
||||||
external_secret: "replace-with-a-strong-random-secret"
|
|
||||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the server:
|
With v2, artifact calls also go through the cache server, so jobs must be able to reach it. `cache.v2: false` sends them to Gitea directly, unless the Gitea URL has a path or `runner.insecure` is set with HTTPS.
|
||||||
|
|
||||||
```bash
|
|
||||||
gitea-runner -c cache-server-config.yaml cache-server
|
|
||||||
```
|
|
||||||
|
|
||||||
3. On every runner:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
cache:
|
|
||||||
external_server: "http://<cache-server-host>:8088/"
|
|
||||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
|
||||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
|
||||||
```
|
|
||||||
|
|
||||||
Jobs reach the cache server at `external_server`, so when a reverse proxy fronts the server, point `external_server` at the proxy. The cache server itself needs no extra configuration.
|
|
||||||
|
|
||||||
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
|
||||||
|
|
||||||
**S3 / MinIO** — mount object storage as a FUSE filesystem (e.g. [s3fs](https://github.com/s3fs-fuse/s3fs-fuse) or [goofys](https://github.com/kahing/goofys)) and set `cache.dir` to the mount point.
|
|
||||||
|
|
||||||
Flags `--dir`, `--host`, and `--port` on `cache-server` override the corresponding `cache.*` YAML keys; all other settings, including `external_secret`, require the config file.
|
|
||||||
|
|
||||||
#### Official Docker image
|
#### Official Docker image
|
||||||
|
|
||||||
@@ -397,6 +402,10 @@ Both hooks are synchronous and block the job while they run. Either one exiting
|
|||||||
|
|
||||||
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||||
|
|
||||||
|
#### OpenTelemetry (`OTEL_*`)
|
||||||
|
|
||||||
|
Set `OTEL_EXPORTER_OTLP_ENDPOINT` in the runner's environment to send a trace of every job to an OpenTelemetry collector. See **[docs/telemetry.md](docs/telemetry.md)**.
|
||||||
|
|
||||||
#### Local job logs (`log.job.dir`)
|
#### Local job logs (`log.job.dir`)
|
||||||
|
|
||||||
Set `log.job.dir` to a path and the runner writes a copy of every task's log there as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same secrets masked and the job's result on the last line. Off by default, and what Gitea shows does not change.
|
Set `log.job.dir` to a path and the runner writes a copy of every task's log there as `<start time>-task-<id>.log`: the rows exactly as Gitea received them, with the same secrets masked and the job's result on the last line. Off by default, and what Gitea shows does not change.
|
||||||
|
|||||||
+229
-49
@@ -10,6 +10,7 @@ import (
|
|||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json/v2"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -17,10 +18,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -58,10 +59,9 @@ type JobCredential struct {
|
|||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
|
|
||||||
// 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. The tags are the wire format a
|
// InsecureTLS how the runner reaches it; see results.go.
|
||||||
// remote runner registers with.
|
Results string `json:"-"`
|
||||||
Results string `json:"results"`
|
InsecureTLS bool `json:"-"`
|
||||||
InsecureTLS bool `json:"insecure_tls"`
|
|
||||||
|
|
||||||
// PublicURL is this server as a reverse proxy makes the job reach it, not the listen address.
|
// PublicURL is this server as a reverse proxy makes the job reach it, not the listen address.
|
||||||
PublicURL string `json:"public_url"`
|
PublicURL string `json:"public_url"`
|
||||||
@@ -79,7 +79,8 @@ type credEntry struct {
|
|||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
dir string
|
dir string
|
||||||
storage *Storage
|
storage cacheStorage
|
||||||
|
s3 *s3Storage
|
||||||
router *httprouter.Router
|
router *httprouter.Router
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
port int
|
port int
|
||||||
@@ -89,6 +90,9 @@ type Handler struct {
|
|||||||
gcing atomic.Bool
|
gcing atomic.Bool
|
||||||
gcAt time.Time
|
gcAt time.Time
|
||||||
|
|
||||||
|
stopSync context.CancelFunc
|
||||||
|
syncing sync.WaitGroup
|
||||||
|
|
||||||
outboundIP string
|
outboundIP string
|
||||||
|
|
||||||
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
||||||
@@ -118,6 +122,8 @@ type Options struct {
|
|||||||
Dir string
|
Dir string
|
||||||
OutboundIP string
|
OutboundIP string
|
||||||
Port uint16
|
Port uint16
|
||||||
|
S3 *S3Options
|
||||||
|
Upstream string
|
||||||
|
|
||||||
// InternalSecret, when non-empty, enables a control-plane API at
|
// InternalSecret, when non-empty, enables a control-plane API at
|
||||||
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
// /_internal/{register,revoke} that lets a remote runner pre-register the
|
||||||
@@ -130,7 +136,7 @@ type Options struct {
|
|||||||
Logger logrus.FieldLogger
|
Logger logrus.FieldLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartHandler opens the on-disk cache store and starts the HTTP server.
|
// 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{
|
||||||
@@ -148,6 +154,25 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
logger = logger.WithField("module", "artifactcache")
|
logger = logger.WithField("module", "artifactcache")
|
||||||
h.logger = logger
|
h.logger = logger
|
||||||
|
|
||||||
|
if opts.OutboundIP != "" {
|
||||||
|
h.outboundIP = opts.OutboundIP
|
||||||
|
} else if ip := common.GetOutboundIP(); ip == nil {
|
||||||
|
return nil, errors.New("unable to determine outbound IP address")
|
||||||
|
} else {
|
||||||
|
h.outboundIP = ip.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Upstream != "" {
|
||||||
|
handler, err := h.cacheForwarder(opts.Upstream)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := h.serve(opts.Port, handler); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -166,13 +191,11 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
h.storage = storage
|
h.storage = storage
|
||||||
|
if opts.S3 != nil {
|
||||||
if opts.OutboundIP != "" {
|
if h.s3, err = newS3Storage(storage, *opts.S3); err != nil {
|
||||||
h.outboundIP = opts.OutboundIP
|
return nil, err
|
||||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
}
|
||||||
return nil, errors.New("unable to determine outbound IP address")
|
h.storage = h.s3
|
||||||
} else {
|
|
||||||
h.outboundIP = ip.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret, err := loadOrCreateSecret(dir)
|
secret, err := loadOrCreateSecret(dir)
|
||||||
@@ -202,39 +225,79 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
|
|
||||||
h.gcCache()
|
h.gcCache()
|
||||||
|
|
||||||
|
if err := h.serve(opts.Port, router); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if h.s3 != nil {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
h.stopSync = cancel
|
||||||
|
h.syncing.Go(func() { h.syncMetadata(ctx) })
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serve(port uint16, handler http.Handler) error {
|
||||||
// Listen on all interfaces. Binding to outboundIP only would give no real
|
// Listen on all interfaces. Binding to outboundIP only would give no real
|
||||||
// security benefit (it is the LAN/internet-facing address either way) and
|
// security benefit (it is the LAN/internet-facing address either way) and
|
||||||
// can break Docker Desktop variants where the host's outbound IP is not
|
// can break Docker Desktop variants where the host's outbound IP is not
|
||||||
// routable from inside the container network. Authentication is enforced
|
// routable from inside the container network. Authentication is enforced
|
||||||
// by the bearer middleware and per-repo scoping, not by reachability.
|
// by the bearer middleware and per-repo scoping, not by reachability.
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", opts.Port))
|
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
addr, ok := listener.Addr().(*net.TCPAddr)
|
addr, ok := listener.Addr().(*net.TCPAddr)
|
||||||
if !ok {
|
if !ok {
|
||||||
listener.Close()
|
listener.Close()
|
||||||
return nil, fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr())
|
return fmt.Errorf("cache server listens on %T, want a TCP address", listener.Addr())
|
||||||
}
|
}
|
||||||
h.port = addr.Port
|
h.port = addr.Port
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
ReadHeaderTimeout: 2 * time.Second,
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
Handler: router,
|
Handler: handler,
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
|
if err := server.Serve(listener); err != nil && errors.Is(err, net.ErrClosed) {
|
||||||
logger.Errorf("http serve: %v", err)
|
h.logger.Errorf("http serve: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
h.listener = listener
|
h.listener = listener
|
||||||
h.server = server
|
h.server = server
|
||||||
|
|
||||||
return h, nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) cacheForwarder(upstream string) (http.Handler, error) {
|
||||||
|
target, err := url.Parse(strings.TrimRight(upstream, "/"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse cache upstream: %w", err)
|
||||||
|
}
|
||||||
|
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||||
|
transport = transport.Clone()
|
||||||
|
transport.MaxIdleConnsPerHost = 64 // parallel block uploads would otherwise redial past the default 2
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Transport: transport,
|
||||||
|
Rewrite: func(r *httputil.ProxyRequest) {
|
||||||
|
r.SetURL(target)
|
||||||
|
r.Out.URL.RawQuery = r.In.URL.RawQuery // Rewrite drops unparsable query params, and signed URLs must pass verbatim.
|
||||||
|
},
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
h.logger.Warnf("cache forward %s %s: %v", r.Method, r.URL.Path, err)
|
||||||
|
h.twirpError(w, r, twirpUnavailable, errors.New("cache upstream is unavailable"))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, apiPath+"/") || strings.HasPrefix(r.URL.Path, cacheServiceV2Path+"/") {
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
h.forwardOrNotFound(w, r)
|
||||||
|
}
|
||||||
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ExternalURL() string {
|
func (h *Handler) ExternalURL() string {
|
||||||
// TODO: make the external url configurable if necessary
|
// TODO: make the external url configurable if necessary
|
||||||
return fmt.Sprintf("http://%s:%d", h.outboundIP, h.port)
|
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) baseURL(cred JobCredential) string {
|
func (h *Handler) baseURL(cred JobCredential) string {
|
||||||
@@ -334,6 +397,10 @@ func (h *Handler) Close() error {
|
|||||||
if h == nil {
|
if h == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if h.stopSync != nil {
|
||||||
|
h.stopSync()
|
||||||
|
h.syncing.Wait()
|
||||||
|
}
|
||||||
var retErr error
|
var retErr error
|
||||||
if h.server != nil {
|
if h.server != nil {
|
||||||
err := h.server.Close()
|
err := h.server.Close()
|
||||||
@@ -367,6 +434,72 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncMetadata mirrors the bucket into the store until ctx ends, so no request waits on S3 for it.
|
||||||
|
func (h *Handler) syncMetadata(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(metadataSyncInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
if err := h.importMetadata(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
h.logger.Warnf("sync S3 cache: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// importMetadata lists after snapshotting the store, so an entry committed meanwhile, published before it is stored, stays.
|
||||||
|
func (h *Handler) importMetadata(ctx context.Context) error {
|
||||||
|
db, err := h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
local := h.caches(db, bolthold.Where("Complete").Eq(true).Index("Complete"))
|
||||||
|
db.Close()
|
||||||
|
|
||||||
|
published, err := h.s3.published(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var gone []*Cache
|
||||||
|
for _, cache := range local {
|
||||||
|
if !published[cache.ID] {
|
||||||
|
gone = append(gone, cache)
|
||||||
|
}
|
||||||
|
delete(published, cache.ID)
|
||||||
|
}
|
||||||
|
added := make([]*Cache, 0, len(published))
|
||||||
|
for id := range published {
|
||||||
|
cache, err := h.s3.fetch(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
added = append(added, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err = h.openDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
return db.Bolt().Update(func(tx *bbolt.Tx) error {
|
||||||
|
for _, cache := range added {
|
||||||
|
if err := db.TxUpsert(tx, cache.ID, cache); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, cache := range gone {
|
||||||
|
_ = h.s3.Storage.Remove(cache.ID) // a blob kept on disk before the runner used S3
|
||||||
|
if err := db.TxDelete(tx, cache.ID, cache); err != nil && !errors.Is(err, bolthold.ErrNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// GET /_apis/artifactcache/cache
|
// GET /_apis/artifactcache/cache
|
||||||
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
@@ -409,7 +542,7 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
_ = db.Delete(cache.ID, cache)
|
h.deleteCache(db, cache)
|
||||||
return nil, nil //nolint:nilnil // absence is not an error here
|
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
|
// Handing out a download URL counts as access, or eviction could drop the entry between
|
||||||
@@ -441,7 +574,7 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
cache.CreatedAt = now
|
cache.CreatedAt = now
|
||||||
cache.UsedAt = now
|
cache.UsedAt = now
|
||||||
if err := insertCache(db, cache); err != nil {
|
if err := h.insertCache(db, cache); err != nil {
|
||||||
h.responseJSON(w, r, 500, err)
|
h.responseJSON(w, r, 500, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -555,6 +688,12 @@ func (h *Handler) commitCache(cache *Cache) error {
|
|||||||
cache.Size = written
|
cache.Size = written
|
||||||
cache.Complete = true
|
cache.Complete = true
|
||||||
cache.UsedAt = time.Now().Unix() // a just-written entry counts as accessed, so it cannot be its own eviction victim
|
cache.UsedAt = time.Now().Unix() // a just-written entry counts as accessed, so it cannot be its own eviction victim
|
||||||
|
if h.s3 != nil {
|
||||||
|
if err := h.s3.publish(cache); err != nil {
|
||||||
|
_ = h.storage.Remove(cache.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db, err := h.openDB()
|
db, err := h.openDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -564,6 +703,9 @@ func (h *Handler) commitCache(cache *Cache) error {
|
|||||||
if err := db.Update(cache.ID, cache); err != nil {
|
if err := db.Update(cache.ID, cache); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if h.s3 != nil {
|
||||||
|
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
|
// A commit is the only thing that grows the store, so the only thing that can push the
|
||||||
// volume under the floor.
|
// volume under the floor.
|
||||||
h.evictRepo(db, cache.Repo)
|
h.evictRepo(db, cache.Repo)
|
||||||
@@ -603,12 +745,12 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||||
token := bearerToken(r)
|
token := bearerToken(r)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
|
h.unauthorized(w, r, errors.New("missing bearer token"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cred, ok := h.lookupCredential(token)
|
cred, ok := h.lookupCredential(token)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
|
h.unauthorized(w, r, errors.New("unknown bearer token"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||||
@@ -617,6 +759,14 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, cacheServiceV2Path) {
|
||||||
|
h.twirpError(w, r, twirpUnauthenticated, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.responseJSON(w, r, http.StatusUnauthorized, err)
|
||||||
|
}
|
||||||
|
|
||||||
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
||||||
@@ -701,8 +851,7 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.RegisterJob(body.Token, body.JobCredential)
|
h.RegisterJob(body.Token, body.JobCredential)
|
||||||
// A server too old to forward answers without this, which is how the caller knows.
|
h.responseJSON(w, r, http.StatusOK)
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{"results_url": h.ResultsURL(body.JobCredential)})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /_internal/revoke
|
// POST /_internal/revoke
|
||||||
@@ -720,6 +869,12 @@ func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httpr
|
|||||||
h.responseJSON(w, r, http.StatusOK)
|
h.responseJSON(w, r, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashedToken fingerprints a job's bearer, so a reservation can tell its own retry from another job.
|
||||||
|
func hashedToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
func bearerToken(r *http.Request) string {
|
func bearerToken(r *http.Request) string {
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
const prefix = "Bearer "
|
const prefix = "Bearer "
|
||||||
@@ -770,14 +925,9 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
|
|||||||
if exact != nil {
|
if exact != nil {
|
||||||
return exact, nil
|
return exact, nil
|
||||||
}
|
}
|
||||||
prefixPattern := "^" + regexp.QuoteMeta(prefix)
|
|
||||||
re, err := regexp.Compile(prefixPattern)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := db.FindOne(cache,
|
if err := db.FindOne(cache,
|
||||||
bolthold.Where("Repo").Eq(repo).
|
bolthold.Where("Repo").Eq(repo).Index("Repo").
|
||||||
And("Key").RegExp(re).
|
And("Key").MatchFunc(func(key string) (bool, error) { return strings.HasPrefix(key, prefix), nil }).
|
||||||
And("Version").Eq(version).
|
And("Version").Eq(version).
|
||||||
And("Complete").Eq(true).
|
And("Complete").Eq(true).
|
||||||
SortBy("CreatedAt").Reverse()); err != nil {
|
SortBy("CreatedAt").Reverse()); err != nil {
|
||||||
@@ -805,8 +955,8 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
|||||||
}
|
}
|
||||||
cache := &Cache{}
|
cache := &Cache{}
|
||||||
err := db.FindOne(cache,
|
err := db.FindOne(cache,
|
||||||
bolthold.Where("Repo").Eq(repo).
|
bolthold.Where("Key").Eq(key).Index("Key").
|
||||||
And("Key").Eq(key).
|
And("Repo").Eq(repo).
|
||||||
And("Version").Eq(version).
|
And("Version").Eq(version).
|
||||||
And("Complete").Eq(complete).
|
And("Complete").Eq(complete).
|
||||||
SortBy(sortBy).Reverse())
|
SortBy(sortBy).Reverse())
|
||||||
@@ -819,15 +969,23 @@ func findExactCache(db *bolthold.Store, repo, key, version string, complete bool
|
|||||||
return cache, nil
|
return cache, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func insertCache(db *bolthold.Store, cache *Cache) error {
|
func (h *Handler) insertCache(db *bolthold.Store, cache *Cache) error {
|
||||||
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
|
if h.s3 != nil {
|
||||||
return fmt.Errorf("insert cache: %w", err)
|
var id [8]byte
|
||||||
|
_, _ = rand.Read(id[:])
|
||||||
|
cache.ID = binary.BigEndian.Uint64(id[:]) >> 11 // IDs must be unique across runners, and v1 clients parse an ID as a JS number
|
||||||
|
return db.Insert(cache.ID, cache)
|
||||||
}
|
}
|
||||||
// write back id to db
|
return db.Bolt().Update(func(tx *bbolt.Tx) error {
|
||||||
if err := db.Update(cache.ID, cache); err != nil {
|
if err := db.TxInsert(tx, bolthold.NextSequence(), cache); err != nil {
|
||||||
return fmt.Errorf("write back id to db: %w", err)
|
return fmt.Errorf("insert cache: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
// write back id to db
|
||||||
|
if err := db.TxUpdate(tx, cache.ID, cache); err != nil {
|
||||||
|
return fmt.Errorf("write back id to db: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
|
||||||
@@ -852,6 +1010,9 @@ func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
|
|||||||
if requireIncomplete && cache.Complete {
|
if requireIncomplete && cache.Complete {
|
||||||
return fmt.Errorf("cache %d: already complete", id)
|
return fmt.Errorf("cache %d: already complete", id)
|
||||||
}
|
}
|
||||||
|
if !touchNeeded(cache) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
cache.UsedAt = time.Now().Unix()
|
cache.UsedAt = time.Now().Unix()
|
||||||
return db.Update(cache.ID, cache)
|
return db.Update(cache.ID, cache)
|
||||||
}
|
}
|
||||||
@@ -861,15 +1022,19 @@ const (
|
|||||||
|
|
||||||
defaultSweepInterval = time.Hour
|
defaultSweepInterval = time.Hour
|
||||||
|
|
||||||
// inUseGrace matches artifactURLTTL so an entry outlives every signed URL still usable
|
touchStale = time.Minute // how stale a completed entry's UsedAt may get before an access rewrites it, sparing hits the fsync
|
||||||
// for it, and no sweep cuts off a download in progress.
|
|
||||||
inUseGrace = artifactURLTTL
|
// inUseGrace covers artifactURLTTL plus the touchStale lag so an entry outlives every signed URL
|
||||||
|
// still usable for it, and no sweep cuts off a download in progress.
|
||||||
|
inUseGrace = artifactURLTTL + touchStale
|
||||||
|
|
||||||
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts
|
// uploadStallTimeout is how long a reservation may sit without a chunk before it counts
|
||||||
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize
|
// as abandoned. Widening it also widens the window for findExactCache to hand a finalize
|
||||||
// a stale reservation.
|
// a stale reservation.
|
||||||
uploadStallTimeout = 5 * time.Minute
|
uploadStallTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
metadataSyncInterval = 30 * time.Second
|
||||||
|
|
||||||
defaultMinFreeDisk = 1024 * miB
|
defaultMinFreeDisk = 1024 * miB
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -917,6 +1082,9 @@ func (h *Handler) gcCache() {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
h.evictIncomplete(db)
|
h.evictIncomplete(db)
|
||||||
|
if h.s3 != nil {
|
||||||
|
return // lifecycle rules on the bucket expire its entries
|
||||||
|
}
|
||||||
h.evictExpired(db)
|
h.evictExpired(db)
|
||||||
h.evictSuperseded(db)
|
h.evictSuperseded(db)
|
||||||
h.evictOversized(db)
|
h.evictOversized(db)
|
||||||
@@ -957,7 +1125,7 @@ func (h *Handler) evictExpired(db *bolthold.Store) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
|
// Never below inUseGrace, or a short retention would outrun a signed URL already issued.
|
||||||
window := max(h.policy.Retention, inUseGrace)
|
window := max(h.policy.Retention+touchStale, inUseGrace)
|
||||||
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
h.sweep(db, bolthold.Where("UsedAt").Lt(time.Now().Add(-window).Unix()).Index("UsedAt"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,10 +1226,18 @@ func inUse(cache *Cache) bool {
|
|||||||
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
|
return time.Since(time.Unix(cache.UsedAt, 0)) < inUseGrace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// touchNeeded skips a write that would not change the second, and completed entries fresher than touchStale.
|
||||||
|
func touchNeeded(cache *Cache) bool {
|
||||||
|
return cache.UsedAt < time.Now().Unix() && (!cache.Complete || time.Since(time.Unix(cache.UsedAt, 0)) >= touchStale)
|
||||||
|
}
|
||||||
|
|
||||||
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
|
// touch stamps UsedAt through the caller's store, a bolt write on the read path. It cannot
|
||||||
// go through touchCache, which opens its own store and would block on the exclusive lock
|
// go through touchCache, which opens its own store and would block on the exclusive lock
|
||||||
// for as long as the caller holds one.
|
// for as long as the caller holds one.
|
||||||
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
|
func (h *Handler) touch(db *bolthold.Store, cache *Cache) {
|
||||||
|
if !touchNeeded(cache) {
|
||||||
|
return
|
||||||
|
}
|
||||||
cache.UsedAt = time.Now().Unix()
|
cache.UsedAt = time.Now().Unix()
|
||||||
if err := db.Update(cache.ID, cache); err != nil {
|
if err := db.Update(cache.ID, cache); err != nil {
|
||||||
h.logger.Warnf("touch cache: %v", err)
|
h.logger.Warnf("touch cache: %v", err)
|
||||||
@@ -1106,7 +1282,11 @@ func totalSize(caches []*Cache) int64 {
|
|||||||
// deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes
|
// deleteCache drops an entry and its bytes, reporting whether it went fully. The blob goes
|
||||||
// first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes.
|
// first, so a failed unlink leaves the row for the next sweep instead of orphaning bytes.
|
||||||
func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool {
|
func (h *Handler) deleteCache(db *bolthold.Store, cache *Cache) bool {
|
||||||
if err := h.storage.Remove(cache.ID); err != nil {
|
storage := h.storage
|
||||||
|
if h.s3 != nil && !cache.Complete {
|
||||||
|
storage = h.s3.Storage // an upload not yet committed has nothing in the bucket
|
||||||
|
}
|
||||||
|
if err := storage.Remove(cache.ID); err != nil {
|
||||||
h.logger.Warnf("remove cache blob: %v", err)
|
h.logger.Warnf("remove cache blob: %v", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -52,6 +53,59 @@ func signArtifactURL(h *Handler, id int64) string {
|
|||||||
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
|
return h.signedArtifactURL(JobCredential{}, uint64(id), time.Now().Add(artifactURLTTL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_ExternalURL(t *testing.T) {
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_Upstream(t *testing.T) {
|
||||||
|
var upstream *httptest.Server
|
||||||
|
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPut, r.Method)
|
||||||
|
assert.Equal(t, "/cache%20server"+blobPath+"/%34%32", r.URL.EscapedPath())
|
||||||
|
assert.Equal(t, "sig=opaque%2Fvalue&raw=a;b", r.URL.RawQuery)
|
||||||
|
assert.Equal(t, strings.TrimPrefix(upstream.URL, "http://"), r.Host)
|
||||||
|
assert.Equal(t, "Bearer unregistered", r.Header.Get("Authorization"))
|
||||||
|
_, err := io.Copy(w, r.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
|
handler, err := StartHandler(Options{
|
||||||
|
Dir: dir,
|
||||||
|
OutboundIP: "127.0.0.1",
|
||||||
|
Upstream: upstream.URL + "/cache%20server//",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, handler.Close()) })
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut,
|
||||||
|
handler.ExternalURL()+blobPath+"/%34%32?sig=opaque%2Fvalue&raw=a;b", strings.NewReader("cache content"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer unregistered")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
forwarded, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "cache content", string(forwarded))
|
||||||
|
|
||||||
|
resp, err = http.Post(handler.ExternalURL()+internalPath+"/register", "application/json", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
|
||||||
|
upstream.Close()
|
||||||
|
resp, err = http.Post(handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", "application/json", strings.NewReader(`{}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var failure map[string]string
|
||||||
|
err = json.UnmarshalRead(resp.Body, &failure)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||||
|
assert.Equal(t, map[string]string{"code": "unavailable", "msg": "cache upstream is unavailable"}, failure)
|
||||||
|
assert.NoDirExists(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandler(t *testing.T) {
|
func TestHandler(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(Options{Dir: dir})
|
handler, err := StartHandler(Options{Dir: dir})
|
||||||
@@ -767,7 +821,7 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
Key: "test_key_3",
|
Key: "test_key_3",
|
||||||
Version: "test_version",
|
Version: "test_version",
|
||||||
Complete: true,
|
Complete: true,
|
||||||
UsedAt: now.Add(-(testRetention + time.Second)).Unix(),
|
UsedAt: now.Add(-(testRetention + touchStale + time.Second)).Unix(),
|
||||||
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
CreatedAt: now.Add(-(testRetention + time.Hour)).Unix(),
|
||||||
},
|
},
|
||||||
Kept: false,
|
Kept: false,
|
||||||
@@ -810,7 +864,7 @@ func TestHandler_gcCache(t *testing.T) {
|
|||||||
db, err := handler.openDB()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
require.NoError(t, insertCache(db, c.Cache))
|
require.NoError(t, handler.insertCache(db, c.Cache))
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
@@ -982,7 +1036,7 @@ func TestHandler_SweepKeepsEntryWhenBlobSurvives(t *testing.T) {
|
|||||||
handler := newTestHandler(t, Policy{Retention: testRetention}, cache)
|
handler := newTestHandler(t, Policy{Retention: testRetention}, cache)
|
||||||
|
|
||||||
// A non-empty directory where the blob belongs makes os.Remove fail on every platform.
|
// A non-empty directory where the blob belongs makes os.Remove fail on every platform.
|
||||||
blob := handler.storage.filename(cache.ID)
|
blob := handler.storage.(*Storage).filename(cache.ID)
|
||||||
require.NoError(t, os.MkdirAll(blob, 0o755))
|
require.NoError(t, os.MkdirAll(blob, 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600))
|
require.NoError(t, os.WriteFile(filepath.Join(blob, "held"), []byte("x"), 0o600))
|
||||||
|
|
||||||
@@ -1042,27 +1096,51 @@ func TestHandler_gcCacheInterval(t *testing.T) {
|
|||||||
assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
|
assert.Empty(t, keptKeys(t, handler, []*Cache{cache}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
|
||||||
|
fresh := &Cache{Repo: testRepo, Key: "fresh", Version: "v", Complete: true, UsedAt: time.Now().Unix()}
|
||||||
|
pending := &Cache{Repo: testRepo, Key: "pending", Version: "v", UsedAt: time.Now().Add(-time.Second).Unix()}
|
||||||
|
handler := newTestHandler(t, Policy{}, fresh, pending)
|
||||||
|
txID := func() int {
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
var id int
|
||||||
|
require.NoError(t, db.Bolt().View(func(tx *bbolt.Tx) error { id = tx.ID(); return nil }))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
before := txID()
|
||||||
|
require.NoError(t, handler.touchCache(fresh.ID, false))
|
||||||
|
assert.Equal(t, before, txID())
|
||||||
|
require.NoError(t, handler.touchCache(pending.ID, false))
|
||||||
|
assert.Greater(t, txID(), before)
|
||||||
|
}
|
||||||
|
|
||||||
// newTestHandler starts a handler with testToken registered, seeded with entries.
|
// newTestHandler starts a handler with testToken registered, seeded with entries.
|
||||||
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, err := StartHandler(Options{
|
handler := startTestHandler(t, Options{Policy: policy})
|
||||||
Dir: filepath.Join(t.TempDir(), "artifactcache"),
|
|
||||||
OutboundIP: "127.0.0.1",
|
|
||||||
Policy: policy,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { require.NoError(t, handler.Close()) })
|
|
||||||
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
|
||||||
|
|
||||||
db, err := handler.openDB()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
require.NoError(t, insertCache(db, e))
|
require.NoError(t, handler.insertCache(db, e))
|
||||||
}
|
}
|
||||||
require.NoError(t, db.Close())
|
require.NoError(t, db.Close())
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startTestHandler(t *testing.T, opts Options) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
opts.Dir = filepath.Join(t.TempDir(), "artifactcache")
|
||||||
|
opts.OutboundIP = "127.0.0.1"
|
||||||
|
handler, err := StartHandler(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, handler.Close()) })
|
||||||
|
handler.RegisterJob(testToken, JobCredential{Repo: testRepo})
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
// keptKeys reports which of entries are still in the store.
|
// keptKeys reports which of entries are still in the store.
|
||||||
func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string {
|
func keptKeys(t *testing.T, handler *Handler, entries []*Cache) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1403,8 +1481,8 @@ func TestHandler_GC_PerRepoDedup(t *testing.T) {
|
|||||||
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}
|
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
|
||||||
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
|
||||||
require.NoError(t, insertCache(db, a))
|
require.NoError(t, handler.insertCache(db, a))
|
||||||
require.NoError(t, insertCache(db, b))
|
require.NoError(t, handler.insertCache(db, b))
|
||||||
// Write the backing blobs so the dedup deletion has something to remove.
|
// Write the backing blobs so the dedup deletion has something to remove.
|
||||||
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
|
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
|
||||||
_, err = handler.storage.Commit(a.ID, 1)
|
_, err = handler.storage.Commit(a.ID, 1)
|
||||||
@@ -1498,7 +1576,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"}`))
|
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}`))
|
||||||
|
cred, ok := handler.lookupCredential("via-internal-api")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, JobCredential{Repo: testRepo, 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"))
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ const (
|
|||||||
|
|
||||||
blobUploadURLTTL = time.Hour
|
blobUploadURLTTL = time.Hour
|
||||||
|
|
||||||
// twirpInternal is the only error code that is not the client's fault.
|
twirpInternal = "internal"
|
||||||
twirpInternal = "internal"
|
twirpUnavailable = "unavailable"
|
||||||
|
twirpUnauthenticated = "unauthenticated"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
||||||
@@ -56,7 +57,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Key == "" || req.Version == "" {
|
if req.Key == "" || req.Version == "" {
|
||||||
@@ -83,16 +84,35 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A second live reservation is finalized by whichever job calls last, against the other's upload.
|
||||||
|
owner := hashedToken(bearerToken(r))
|
||||||
|
if pending, err := findExactCache(db, cred.Repo, req.Key, req.Version, false); err != nil {
|
||||||
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
|
return
|
||||||
|
} else if pending != nil && pending.UsedAt > time.Now().Add(-uploadStallTimeout).Unix() {
|
||||||
|
if pending.Owner != owner {
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.touch(db, pending) // still uploading, so it must not go stale under the sweep
|
||||||
|
h.responseJSON(w, r, http.StatusOK, map[string]any{ // this job retrying its own reservation
|
||||||
|
"ok": true,
|
||||||
|
"signed_upload_url": h.signedURL(cred, blobPath, blobUploadPurpose, pending.ID, time.Now().Add(blobUploadURLTTL)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
cache := &Cache{
|
cache := &Cache{
|
||||||
Repo: cred.Repo,
|
Repo: cred.Repo,
|
||||||
Key: req.Key,
|
Key: req.Key,
|
||||||
Version: req.Version,
|
Version: req.Version,
|
||||||
|
Owner: owner,
|
||||||
Size: -1, // the size is only known at finalize time
|
Size: -1, // the size is only known at finalize time
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UsedAt: now,
|
UsedAt: now,
|
||||||
}
|
}
|
||||||
if err := insertCache(db, cache); err != nil {
|
if err := h.insertCache(db, cache); err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -107,7 +127,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +139,13 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
|
||||||
|
if err == nil && cache != nil && cache.Owner != hashedToken(bearerToken(r)) {
|
||||||
|
cache = nil // not the reservation this job made, so not this job's to commit
|
||||||
|
}
|
||||||
|
if err == nil && cache == nil {
|
||||||
|
// A retry whose first response was lost finds the entry already committed.
|
||||||
|
cache, err = findExactCache(db, cred.Repo, req.Key, req.Version, true)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, twirpInternal, err)
|
h.twirpError(w, r, twirpInternal, err)
|
||||||
return
|
return
|
||||||
@@ -127,13 +154,15 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
h.twirpNotOK(w, r)
|
h.twirpNotOK(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.Close() // commitCache needs the store closed
|
|
||||||
|
|
||||||
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
|
if !cache.Complete {
|
||||||
if err := h.commitCache(cache); err != nil {
|
db.Close() // commitCache needs the store closed
|
||||||
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
cache.Size = int64(cmp.Or(req.SizeBytes, req.SizeBytesCamel))
|
||||||
h.twirpNotOK(w, r)
|
if err := h.commitCache(cache); err != nil {
|
||||||
return
|
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
|
||||||
|
h.twirpNotOK(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
h.responseJSON(w, r, http.StatusOK, map[string]any{
|
||||||
@@ -147,7 +176,7 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ
|
|||||||
cred := credFromContext(r.Context())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.twirpError(w, r, "malformed_request", err)
|
h.twirpError(w, r, "malformed", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,8 +259,13 @@ func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
|
||||||
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
|
||||||
status := http.StatusBadRequest
|
status := http.StatusBadRequest
|
||||||
if code == twirpInternal {
|
switch code {
|
||||||
|
case twirpInternal:
|
||||||
status = http.StatusInternalServerError
|
status = http.StatusInternalServerError
|
||||||
|
case twirpUnavailable:
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
case twirpUnauthenticated:
|
||||||
|
status = http.StatusUnauthorized
|
||||||
}
|
}
|
||||||
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -141,6 +142,17 @@ func TestCacheServiceV2BlockUpload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
list.WriteString(`</BlockList>`)
|
list.WriteString(`</BlockList>`)
|
||||||
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()),
|
||||||
|
"a client that lost the first answer retries the list it already sent")
|
||||||
|
|
||||||
|
var reordered bytes.Buffer
|
||||||
|
reordered.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
|
||||||
|
for _, blockID := range []string{order[1], order[0], order[2]} {
|
||||||
|
fmt.Fprintf(&reordered, "<Latest>%s</Latest>", blockID)
|
||||||
|
}
|
||||||
|
reordered.WriteString(`</BlockList>`)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, putBlob(t, uploadURL+"&comp=blocklist", reordered.Bytes()),
|
||||||
|
"the blocks are already assembled, so a different order would silently disagree with them")
|
||||||
|
|
||||||
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
|
||||||
@@ -241,3 +253,101 @@ func TestCacheServiceV2Lookups(t *testing.T) {
|
|||||||
assert.Equal(t, false, got["ok"])
|
assert.Equal(t, false, got["ok"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2RefusalsAreTwirp(t *testing.T) {
|
||||||
|
handler := newTestHandler(t, Policy{})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
body string
|
||||||
|
status int
|
||||||
|
code string
|
||||||
|
}{
|
||||||
|
{"no bearer at all", "", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||||
|
{"a bearer nobody registered", "not-a-job", `{"key":"k","version":"v"}`, http.StatusUnauthorized, twirpUnauthenticated},
|
||||||
|
{"a body that is not the request", testToken, `{`, http.StatusBadRequest, "malformed"},
|
||||||
|
{"a request missing its key", testToken, `{"version":"v"}`, http.StatusBadRequest, "invalid_argument"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
handler.ExternalURL()+cacheServiceV2Path+"/CreateCacheEntry", strings.NewReader(tt.body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tt.token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, tt.status, resp.StatusCode)
|
||||||
|
got := map[string]string{}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &got))
|
||||||
|
assert.Equal(t, tt.code, got["code"])
|
||||||
|
assert.NotEmpty(t, got["msg"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheServiceV2ConcurrentSaveAndRetries(t *testing.T) {
|
||||||
|
handler := newTestHandler(t, Policy{})
|
||||||
|
content := []byte("the cached archive")
|
||||||
|
|
||||||
|
const otherJob = "another-jobs-token"
|
||||||
|
defer handler.RegisterJob(otherJob, JobCredential{Repo: testRepo})()
|
||||||
|
reserve := func(client *http.Client) map[string]any {
|
||||||
|
return v2Call(t, handler, client, "CreateCacheEntry", map[string]any{"key": "shared", "version": "v1"})
|
||||||
|
}
|
||||||
|
first := reserve(testClient)
|
||||||
|
require.Equal(t, true, first["ok"])
|
||||||
|
retry := reserve(testClient)
|
||||||
|
assert.Equal(t, true, retry["ok"], "a job retrying its own reservation is not a conflict")
|
||||||
|
entryOf := func(reserved map[string]any) string {
|
||||||
|
parsed, err := url.Parse(reserved["signed_upload_url"].(string))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return parsed.Path
|
||||||
|
}
|
||||||
|
assert.Equal(t, entryOf(first), entryOf(retry), "the same reservation, however freshly its URL is signed")
|
||||||
|
assert.Equal(t, false, reserve(&http.Client{Transport: &bearerTransport{token: otherJob}})["ok"],
|
||||||
|
"another job's reservation would be finalized against the first upload")
|
||||||
|
|
||||||
|
otherClient := &http.Client{Transport: &bearerTransport{token: otherJob}}
|
||||||
|
reserved := v2Call(t, handler, otherClient, "CreateCacheEntry", map[string]any{"key": "owned", "version": "v1"})
|
||||||
|
require.Equal(t, true, reserved["ok"])
|
||||||
|
require.Equal(t, http.StatusCreated, putBlob(t, reserved["signed_upload_url"].(string), content))
|
||||||
|
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||||
|
map[string]any{"key": "owned", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||||
|
"another job's upload is not this job's to finalize")
|
||||||
|
|
||||||
|
base := handler.ExternalURL() + apiPath
|
||||||
|
reserveV1, err := json.Marshal(&Request{Key: "v1-made", Version: "v1", Size: int64(len(content))})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(reserveV1))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var madeByV1 struct {
|
||||||
|
CacheID uint64 `json:"cacheId"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &madeByV1))
|
||||||
|
resp.Body.Close()
|
||||||
|
upload, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
|
||||||
|
fmt.Sprintf("%s/caches/%d", base, madeByV1.CacheID), bytes.NewReader(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
upload.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
|
||||||
|
resp, err = testClient.Do(upload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
assert.Equal(t, false, v2Call(t, handler, testClient, "FinalizeCacheEntryUpload",
|
||||||
|
map[string]any{"key": "v1-made", "version": "v1", "size_bytes": strconv.Itoa(len(content))})["ok"],
|
||||||
|
"a reservation this job did not make through v2 is not its to commit")
|
||||||
|
|
||||||
|
finalized, _ := saveV2(t, handler, "deps", "v1", content)
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
again := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
|
||||||
|
"key": "deps", "version": "v1", "size_bytes": strconv.Itoa(len(content)),
|
||||||
|
})
|
||||||
|
assert.Equal(t, true, again["ok"], "a retry whose first answer was lost must not report a failed save")
|
||||||
|
assert.Equal(t, finalized["entry_id"], again["entry_id"])
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ type Cache struct {
|
|||||||
Key string `json:"key" boltholdIndex:"Key"`
|
Key string `json:"key" boltholdIndex:"Key"`
|
||||||
Version string `json:"version" boltholdIndex:"Version"`
|
Version string `json:"version" boltholdIndex:"Version"`
|
||||||
Size int64 `json:"cacheSize"`
|
Size int64 `json:"cacheSize"`
|
||||||
|
Owner string `json:"owner"` // hashed bearer, so a job's own retry is not another job's reservation
|
||||||
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
Complete bool `json:"complete" boltholdIndex:"Complete"`
|
||||||
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
UsedAt int64 `json:"usedAt" boltholdIndex:"UsedAt"`
|
||||||
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
CreatedAt int64 `json:"createdAt" boltholdIndex:"CreatedAt"`
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package artifactcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -15,23 +17,38 @@ import (
|
|||||||
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
|
// Gitea implements only the artifact half of it. Forwarding that half from here makes this origin
|
||||||
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
|
// the whole service, so ACTIONS_RESULTS_URL can point at it truthfully, which is what the clients
|
||||||
// this runner cannot patch need, docker buildx among them.
|
// this runner cannot patch need, docker buildx among them.
|
||||||
//
|
|
||||||
// The instance to forward to travels with the job registration rather than with configuration, so
|
|
||||||
// a cache server shared between runners serves each of their instances.
|
|
||||||
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
|
const artifactServicePath = "/twirp/github.actions.results.api.v1.ArtifactService/"
|
||||||
|
|
||||||
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
// forwardOrNotFound is the router's fallback: the artifact service of the instance the job
|
||||||
// registered with, and the 404 the router would have written otherwise.
|
// registered with, and the 404 the router would have written otherwise.
|
||||||
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
||||||
cred, ok := h.lookupCredential(bearerToken(r))
|
if !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
||||||
if !ok || cred.Results == "" || !strings.HasPrefix(r.URL.Path, artifactServicePath) {
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cred, ok := h.lookupCredential(bearerToken(r))
|
||||||
|
if !ok {
|
||||||
|
h.twirpError(w, r, twirpUnauthenticated, errors.New("unknown bearer token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cred.Results == "" {
|
||||||
|
h.twirpError(w, r, twirpInternal, errors.New("no instance is registered for this job"))
|
||||||
|
return
|
||||||
|
}
|
||||||
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
target, err := url.Parse(strings.TrimSuffix(cred.Results, "/"))
|
||||||
|
if err == nil && (target.Hostname() == "" || (target.Scheme != "http" && target.Scheme != "https")) {
|
||||||
|
err = errors.New("not an absolute http or https URL the job could use")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
h.logger.Errorf("artifact service forward to %q: %v", cred.Results, err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
h.twirpError(w, r, twirpInternal, fmt.Errorf("artifact service address %q is unusable: %w", cred.Results, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.mustProxy(r, cred, target) {
|
||||||
|
redirect := target.JoinPath(r.URL.EscapedPath())
|
||||||
|
redirect.RawQuery = mergeQuery(target.RawQuery, r.URL.RawQuery)
|
||||||
|
h.logger.Debugf("%s %s: redirecting to %s", r.Method, r.URL.Path, target)
|
||||||
|
http.Redirect(w, r, redirect.String(), http.StatusTemporaryRedirect)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
h.logger.Debugf("%s %s: forwarding to %s", r.Method, r.URL.Path, target)
|
||||||
@@ -44,9 +61,9 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
|||||||
// directly and never through here.
|
// directly and never through here.
|
||||||
r.Out.Host = target.Host
|
r.Out.Host = target.Host
|
||||||
},
|
},
|
||||||
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
h.logger.Warnf("artifact service forward to %s: %v", target, err)
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if cred.InsecureTLS {
|
if cred.InsecureTLS {
|
||||||
@@ -55,5 +72,24 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A protocol switch loses the bearer or the agent, and an untrusted instance needs skipped verification.
|
||||||
|
func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL) bool {
|
||||||
|
if cred.InsecureTLS && target.Scheme == "https" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
return scheme != target.Scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeQuery(target, request string) string {
|
||||||
|
if target == "" || request == "" {
|
||||||
|
return target + request
|
||||||
|
}
|
||||||
|
return target + "&" + request
|
||||||
|
}
|
||||||
|
|
||||||
// insecureTransport is shared, because a transport per request would pool no connections.
|
// insecureTransport is shared, because a transport per request would pool no connections.
|
||||||
var insecureTransport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
var insecureTransport = &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // the runner reaches its instance on the operator's say-so
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"encoding/json/v2"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -14,8 +16,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The artifact half is forwarded under the Host Gitea knows itself by, so the URLs it hands back
|
|
||||||
// still point at Gitea, and nothing else is proxied.
|
|
||||||
func TestFrontResultsService(t *testing.T) {
|
func TestFrontResultsService(t *testing.T) {
|
||||||
var gotHost, gotPath, gotProto string
|
var gotHost, gotPath, gotProto string
|
||||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -23,34 +23,112 @@ func TestFrontResultsService(t *testing.T) {
|
|||||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||||
}))
|
}))
|
||||||
defer gitea.Close()
|
defer gitea.Close()
|
||||||
|
selfSigned := httptest.NewTLSServer(gitea.Config.Handler)
|
||||||
|
defer selfSigned.Close()
|
||||||
|
|
||||||
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
const token = "forward-token"
|
const token, rpc = "forward-token", artifactServicePath + "CreateArtifact"
|
||||||
|
|
||||||
client := &http.Client{Transport: &bearerTransport{token: token}}
|
noRedirect := func(token string) *http.Client {
|
||||||
post := func(path string) int {
|
return &http.Client{
|
||||||
|
Transport: &bearerTransport{token: token},
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post := func(path string) (int, map[string]string) {
|
||||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+path, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err := client.Do(req)
|
resp, err := noRedirect(token).Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return resp.StatusCode
|
body := map[string]string{}
|
||||||
|
_ = json.UnmarshalRead(resp.Body, &body)
|
||||||
|
return resp.StatusCode, body
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, post(artifactServicePath+"CreateArtifact"),
|
status, body := post(rpc)
|
||||||
"an unregistered token is forwarded nowhere")
|
assert.Equal(t, http.StatusUnauthorized, status, "an unregistered token is told so, not left to parse a 404 page")
|
||||||
|
assert.Equal(t, twirpUnauthenticated, body["code"])
|
||||||
|
assert.Contains(t, body["msg"], "bearer")
|
||||||
|
assert.NotEmpty(t, body["msg"])
|
||||||
|
|
||||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL})()
|
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: selfSigned.URL, InsecureTLS: true})()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, post(artifactServicePath+"CreateArtifact"))
|
status, _ = post(rpc)
|
||||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
require.Equal(t, http.StatusOK, status, "an http job reaching an https instance must proxy")
|
||||||
|
assert.Equal(t, strings.TrimPrefix(selfSigned.URL, "https://"), gotHost, "Gitea must see the host it mints its URLs from")
|
||||||
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
assert.Empty(t, gotProto, "a forwarded scheme would make an https Gitea mint http URLs")
|
||||||
assert.Equal(t, artifactServicePath+"CreateArtifact", gotPath)
|
assert.Equal(t, rpc, gotPath)
|
||||||
|
|
||||||
gotPath = ""
|
gotPath = ""
|
||||||
assert.Equal(t, http.StatusNotFound, post("/twirp/github.actions.results.api.v1.OtherService/Do"))
|
status, _ = post("/twirp/github.actions.results.api.v1.OtherService/Do")
|
||||||
assert.Equal(t, http.StatusNotFound, post("/api/v1/repos/owner/repo"))
|
assert.Equal(t, http.StatusNotFound, status)
|
||||||
|
status, _ = post("/api/v1/repos/owner/repo")
|
||||||
|
assert.Equal(t, http.StatusNotFound, status)
|
||||||
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
assert.Empty(t, gotPath, "only the artifact service is forwarded")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cred JobCredential
|
||||||
|
forwardedProto string
|
||||||
|
reqQuery string
|
||||||
|
want int
|
||||||
|
location string
|
||||||
|
wantMsg string
|
||||||
|
}{
|
||||||
|
{name: "an instance the job reaches itself", cred: JobCredential{Results: gitea.URL}, want: http.StatusTemporaryRedirect},
|
||||||
|
{name: "a sub-path instance keeps its prefix", cred: JobCredential{Results: gitea.URL + "/sub"}, want: http.StatusTemporaryRedirect},
|
||||||
|
{name: "an instance with no certificate to distrust", cred: JobCredential{Results: gitea.URL, InsecureTLS: true}, want: http.StatusTemporaryRedirect},
|
||||||
|
{
|
||||||
|
name: "both queries survive", cred: JobCredential{Results: gitea.URL + "?tenant=one"}, reqQuery: "?run=7",
|
||||||
|
want: http.StatusTemporaryRedirect, location: gitea.URL + rpc + "?tenant=one&run=7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a trusted https instance is reached by the job itself",
|
||||||
|
cred: JobCredential{Results: selfSigned.URL}, forwardedProto: "https", want: http.StatusTemporaryRedirect,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the job does not share this server's disregard for the certificate",
|
||||||
|
cred: JobCredential{Results: selfSigned.URL, InsecureTLS: true}, forwardedProto: "https", want: http.StatusOK,
|
||||||
|
},
|
||||||
|
{name: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
|
||||||
|
{
|
||||||
|
name: "a forwarded https scheme onto an http instance is proxied",
|
||||||
|
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a scheme the job could not use is refused before it is sent one",
|
||||||
|
cred: JobCredential{Results: "ftp://gitea.example"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an address with no host is refused before it is sent one",
|
||||||
|
cred: JobCredential{Results: "http://:3000"}, want: http.StatusInternalServerError, wantMsg: "unusable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
defer handler.RegisterJob(tt.name, tt.cred)()
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+rpc+tt.reqQuery, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.forwardedProto != "" {
|
||||||
|
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||||
|
}
|
||||||
|
resp, err := noRedirect(tt.name).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
require.Equal(t, tt.want, resp.StatusCode)
|
||||||
|
switch tt.want {
|
||||||
|
case http.StatusTemporaryRedirect:
|
||||||
|
assert.Equal(t, cmp.Or(tt.location, tt.cred.Results+rpc), resp.Header.Get("Location"))
|
||||||
|
case http.StatusInternalServerError:
|
||||||
|
body := map[string]string{}
|
||||||
|
require.NoError(t, json.UnmarshalRead(resp.Body, &body))
|
||||||
|
assert.Equal(t, twirpInternal, body["code"])
|
||||||
|
assert.Contains(t, body["msg"], cmp.Or(tt.wantMsg, tt.cred.Results))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json/v2"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// S3Options configures the bucket that stores completed cache entries.
|
||||||
|
type S3Options struct {
|
||||||
|
Endpoint string
|
||||||
|
Region string
|
||||||
|
Bucket string
|
||||||
|
Prefix string
|
||||||
|
PathStyle *bool // nil uses virtual-hosted requests on AWS and path-style ones elsewhere
|
||||||
|
AccessKey string
|
||||||
|
SecretKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheStorage interface {
|
||||||
|
Exist(uint64) (bool, error)
|
||||||
|
Write(uint64, int64, io.Reader) error
|
||||||
|
WriteBlock(uint64, string, io.Reader) error
|
||||||
|
OrderBlocks(uint64, []string) error
|
||||||
|
Commit(uint64, int64) (int64, error)
|
||||||
|
Serve(http.ResponseWriter, *http.Request, uint64)
|
||||||
|
Remove(uint64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3PartSize = 5 << 30 // the largest part S3 accepts
|
||||||
|
|
||||||
|
// s3Storage stages uploads on disk and keeps completed entries and their metadata in a bucket.
|
||||||
|
type s3Storage struct {
|
||||||
|
*Storage
|
||||||
|
client *http.Client
|
||||||
|
bucketURL *url.URL
|
||||||
|
opts S3Options
|
||||||
|
}
|
||||||
|
|
||||||
|
type s3Part struct {
|
||||||
|
PartNumber int
|
||||||
|
ETag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newS3Storage(staging *Storage, opts S3Options) (*s3Storage, error) {
|
||||||
|
endpoint, err := url.Parse(opts.Endpoint)
|
||||||
|
if err != nil || endpoint.Host == "" || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
|
||||||
|
return nil, fmt.Errorf("invalid S3 endpoint %q", opts.Endpoint)
|
||||||
|
}
|
||||||
|
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||||
|
transport = transport.Clone()
|
||||||
|
transport.ResponseHeaderTimeout = time.Minute // a stalled endpoint must not hold the store forever
|
||||||
|
pathStyle := !strings.HasSuffix(endpoint.Hostname(), ".amazonaws.com")
|
||||||
|
if opts.PathStyle != nil {
|
||||||
|
pathStyle = *opts.PathStyle
|
||||||
|
}
|
||||||
|
if pathStyle {
|
||||||
|
endpoint.Path = "/" + opts.Bucket
|
||||||
|
} else {
|
||||||
|
endpoint.Host = opts.Bucket + "." + endpoint.Host
|
||||||
|
endpoint.Path = "/"
|
||||||
|
}
|
||||||
|
opts.Region = cmp.Or(opts.Region, "us-east-1")
|
||||||
|
return &s3Storage{Storage: staging, client: &http.Client{Transport: transport}, bucketURL: endpoint, opts: opts}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) blobKey(id uint64) string {
|
||||||
|
return path.Join(s.opts.Prefix, "blobs", strconv.FormatUint(id, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) metadataDir() string {
|
||||||
|
return path.Join(s.opts.Prefix, "metadata") + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) metadataKey(id uint64) string {
|
||||||
|
return s.metadataDir() + strconv.FormatUint(id, 10) + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) url(key string, query url.Values) *url.URL {
|
||||||
|
u := *s.bucketURL
|
||||||
|
u.Path = path.Join(u.Path, key)
|
||||||
|
u.RawPath = strings.NewReplacer("+", "%20", "%2F", "/").Replace(url.QueryEscape(u.Path)) // SigV4 encoding
|
||||||
|
u.RawQuery = strings.ReplaceAll(query.Encode(), "+", "%20")
|
||||||
|
return &u
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign authorizes req with AWS Signature Version 4, leaving the payload unsigned so bodies can stream.
|
||||||
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
|
||||||
|
func (s *s3Storage) sign(req *http.Request, now time.Time) {
|
||||||
|
date := now.UTC().Format("20060102T150405Z")
|
||||||
|
req.Header.Set("X-Amz-Date", date)
|
||||||
|
if req.Header.Get("X-Amz-Content-Sha256") == "" {
|
||||||
|
req.Header.Set("X-Amz-Content-Sha256", "UNSIGNED-PAYLOAD")
|
||||||
|
}
|
||||||
|
headers := map[string]string{"host": req.URL.Host}
|
||||||
|
for name, values := range req.Header {
|
||||||
|
if name = strings.ToLower(name); strings.HasPrefix(name, "x-amz-") {
|
||||||
|
headers[name] = strings.Join(values, ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := slices.Sorted(maps.Keys(headers))
|
||||||
|
canonical := []string{req.Method, req.URL.EscapedPath(), req.URL.RawQuery}
|
||||||
|
for _, name := range names {
|
||||||
|
canonical = append(canonical, name+":"+headers[name])
|
||||||
|
}
|
||||||
|
canonical = append(canonical, "", strings.Join(names, ";"), req.Header.Get("X-Amz-Content-Sha256"))
|
||||||
|
scope := date[:8] + "/" + s.opts.Region + "/s3/aws4_request"
|
||||||
|
signature := []byte("AWS4" + s.opts.SecretKey)
|
||||||
|
for _, data := range []string{date[:8], s.opts.Region, "s3", "aws4_request", fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%x", date, scope, sha256.Sum256([]byte(strings.Join(canonical, "\n"))))} { // key derivation, the last round signs
|
||||||
|
mac := hmac.New(sha256.New, signature)
|
||||||
|
_, _ = mac.Write([]byte(data))
|
||||||
|
signature = mac.Sum(nil)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s,SignedHeaders=%s,Signature=%x", s.opts.AccessKey, scope, strings.Join(names, ";"), signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// do returns the response to a signed request when it succeeded, fs.ErrNotExist for a missing key.
|
||||||
|
func (s *s3Storage) do(ctx context.Context, method, key string, query url.Values, header http.Header, body io.Reader) (*http.Response, error) {
|
||||||
|
var size int64
|
||||||
|
if sized, ok := body.(interface{ Size() int64 }); ok {
|
||||||
|
size = sized.Size()
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
body = nil
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, s.url(key, query).String(), body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.ContentLength = size
|
||||||
|
maps.Copy(req.Header, header)
|
||||||
|
s.sign(req, time.Now())
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusMultipleChoices {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
defer drain(resp)
|
||||||
|
var failure struct{ Code, Message string }
|
||||||
|
_ = xml.NewDecoder(resp.Body).Decode(&failure)
|
||||||
|
if resp.StatusCode == http.StatusNotFound && (failure.Code == "NoSuchKey" || failure.Code == "" && method == http.MethodHead) {
|
||||||
|
return nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("S3 %s %s: %s", method, key, cmp.Or(strings.TrimSpace(failure.Code+" "+failure.Message), resp.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) call(ctx context.Context, method, key string, query url.Values, body io.Reader, result any) (http.Header, error) {
|
||||||
|
resp, err := s.do(ctx, method, key, query, nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer drain(resp)
|
||||||
|
if result == nil {
|
||||||
|
return resp.Header, nil
|
||||||
|
}
|
||||||
|
return resp.Header, xml.NewDecoder(resp.Body).Decode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain reads a response to its end before closing it, so its connection gets reused.
|
||||||
|
func drain(resp *http.Response) {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) Exist(id uint64) (bool, error) {
|
||||||
|
_, err := s.call(context.Background(), http.MethodHead, s.blobKey(id), nil, nil, nil)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
|
written, err := s.Storage.Commit(id, size)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = s.Storage.Remove(id) }()
|
||||||
|
file, err := os.Open(s.filename(id))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if err := s.upload(context.Background(), s.blobKey(id), file, written); err != nil {
|
||||||
|
return 0, fmt.Errorf("upload cache to S3: %w", err)
|
||||||
|
}
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload always goes in parts, the only way S3 takes an object over 5 GiB.
|
||||||
|
func (s *s3Storage) upload(ctx context.Context, key string, file io.ReaderAt, size int64) error {
|
||||||
|
var created struct {
|
||||||
|
UploadID string `xml:"UploadId"`
|
||||||
|
}
|
||||||
|
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploads": {""}}, nil, &created); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var completion struct {
|
||||||
|
XMLName xml.Name `xml:"CompleteMultipartUpload"`
|
||||||
|
Parts []s3Part `xml:"Part"`
|
||||||
|
}
|
||||||
|
for offset := int64(0); offset == 0 || offset < size; offset += s3PartSize {
|
||||||
|
number, length := len(completion.Parts)+1, min(s3PartSize, size-offset)
|
||||||
|
query := url.Values{"partNumber": {strconv.Itoa(number)}, "uploadId": {created.UploadID}}
|
||||||
|
header, err := s.call(ctx, http.MethodPut, key, query, io.NewSectionReader(file, offset, length), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
completion.Parts = append(completion.Parts, s3Part{number, header.Get("ETag")})
|
||||||
|
}
|
||||||
|
body, err := xml.Marshal(completion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var result struct{ Code, Message string } // a completion can fail after its 200 status
|
||||||
|
if _, err := s.call(ctx, http.MethodPost, key, url.Values{"uploadId": {created.UploadID}}, bytes.NewReader(body), &result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.Code != "" {
|
||||||
|
return fmt.Errorf("S3 complete upload %s: %s %s", key, result.Code, result.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) Serve(w http.ResponseWriter, r *http.Request, id uint64) {
|
||||||
|
header := http.Header{}
|
||||||
|
if byteRange := r.Header.Get("Range"); byteRange != "" {
|
||||||
|
header.Set("Range", byteRange)
|
||||||
|
}
|
||||||
|
resp, err := s.do(r.Context(), http.MethodGet, s.blobKey(id), nil, header, nil)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
http.Error(w, "read cache from S3", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
for _, name := range []string{"Content-Length", "Content-Range"} {
|
||||||
|
if value := resp.Header.Get(name); value != "" {
|
||||||
|
w.Header().Set(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove unpublishes an entry before deleting its blob, so no runner imports it without its bytes.
|
||||||
|
func (s *s3Storage) Remove(id uint64) error {
|
||||||
|
for _, key := range []string{s.metadataKey(id), s.blobKey(id)} {
|
||||||
|
if _, err := s.call(context.Background(), http.MethodDelete, key, nil, nil, nil); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("remove %s from S3: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.Storage.Remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) publish(cache *Cache) error {
|
||||||
|
data, err := json.Marshal(cache)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.call(context.Background(), http.MethodPut, s.metadataKey(cache.ID), nil, bytes.NewReader(data), nil); err != nil {
|
||||||
|
return fmt.Errorf("publish cache to S3: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) published(ctx context.Context) (map[uint64]bool, error) {
|
||||||
|
ids := make(map[uint64]bool)
|
||||||
|
query := url.Values{"list-type": {"2"}, "prefix": {s.metadataDir()}}
|
||||||
|
for {
|
||||||
|
var page struct {
|
||||||
|
Contents []struct{ Key string }
|
||||||
|
IsTruncated bool
|
||||||
|
NextContinuationToken string
|
||||||
|
}
|
||||||
|
if _, err := s.call(ctx, http.MethodGet, "", query, nil, &page); err != nil {
|
||||||
|
return nil, fmt.Errorf("list S3 cache metadata: %w", err)
|
||||||
|
}
|
||||||
|
for _, object := range page.Contents {
|
||||||
|
if id, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimPrefix(object.Key, s.metadataDir()), ".json"), 10, 64); err == nil {
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !page.IsTruncated {
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
query.Set("continuation-token", page.NextContinuationToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Storage) fetch(ctx context.Context, id uint64) (*Cache, error) {
|
||||||
|
resp, err := s.do(ctx, http.MethodGet, s.metadataKey(id), nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
cache := &Cache{}
|
||||||
|
if err := json.UnmarshalRead(resp.Body, cache); err != nil {
|
||||||
|
return nil, fmt.Errorf("read S3 cache metadata %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package artifactcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/johannesboyne/gofakes3"
|
||||||
|
"github.com/johannesboyne/gofakes3/backend/s3mem"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandler_S3SharesEntriesAndDropsExpiredOnes(t *testing.T) {
|
||||||
|
backend := s3mem.New()
|
||||||
|
require.NoError(t, backend.CreateBucket("cache"))
|
||||||
|
server := httptest.NewServer(gofakes3.New(backend).Server())
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
opts := Options{S3: &S3Options{Endpoint: server.URL, Bucket: "cache", Prefix: "runners", AccessKey: "key", SecretKey: "secret"}}
|
||||||
|
saver, restorer := startTestHandler(t, opts), startTestHandler(t, opts)
|
||||||
|
entry := map[string]any{"key": "deps", "version": "v1"}
|
||||||
|
|
||||||
|
finalized, _ := saveV2(t, saver, "deps", "v1", []byte("archive"))
|
||||||
|
require.Equal(t, true, finalized["ok"])
|
||||||
|
require.NoError(t, restorer.importMetadata(t.Context()))
|
||||||
|
found := v2Call(t, restorer, testClient, "GetCacheEntryDownloadURL", entry)
|
||||||
|
require.Equal(t, true, found["ok"])
|
||||||
|
assert.Equal(t, []byte("archive"), getURL(t, found["signed_download_url"].(string)))
|
||||||
|
|
||||||
|
require.NoError(t, backend.ForceDeleteBucket("cache"))
|
||||||
|
require.NoError(t, backend.CreateBucket("cache"))
|
||||||
|
require.NoError(t, restorer.importMetadata(t.Context()))
|
||||||
|
assert.Equal(t, true, v2Call(t, restorer, testClient, "CreateCacheEntry", entry)["ok"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3StorageSignsLikeAWS(t *testing.T) {
|
||||||
|
storage := &s3Storage{opts: S3Options{Region: "us-east-1", AccessKey: "AKIAIOSFODNN7EXAMPLE", SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", nil)
|
||||||
|
req.Header.Set("X-Amz-Content-Sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||||
|
storage.sign(req, time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC))
|
||||||
|
assert.Equal(t, "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date,Signature=34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7", req.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
package artifactcache
|
package artifactcache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json/v2"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -66,6 +68,18 @@ func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error
|
|||||||
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
|
||||||
// never does, so no rename can collide with a block not yet moved.
|
// never does, so no rename can collide with a block not yet moved.
|
||||||
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
||||||
|
wanted, err := json.Marshal(blockIDs) // a block id is client-chosen, so it cannot be a separator
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list := filepath.Join(s.tempDir(id), blockFilePrefix+"list")
|
||||||
|
if recorded, err := os.ReadFile(list); err == nil {
|
||||||
|
// A retry repeats the list it already sent; another one would reorder what is now staged.
|
||||||
|
if !bytes.Equal(recorded, wanted) {
|
||||||
|
return fmt.Errorf("cache %d was already assembled from a different block list", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
for i, blockID := range blockIDs {
|
for i, blockID := range blockIDs {
|
||||||
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -74,7 +88,7 @@ func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return os.WriteFile(list, wanted, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -220,14 +221,14 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
|||||||
downloads(router, artifactPath)
|
downloads(router, artifactPath)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%s", addr, port),
|
Addr: net.JoinHostPort(addr, port),
|
||||||
ReadHeaderTimeout: 2 * time.Second,
|
ReadHeaderTimeout: 2 * time.Second,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
}
|
}
|
||||||
|
|
||||||
// run server
|
// run server
|
||||||
go func() {
|
go func() {
|
||||||
logger.Infof("Start server on http://%s:%s", addr, port)
|
logger.Infof("Start server on http://%s", server.Addr)
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Fatal(err)
|
logger.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-89
@@ -325,33 +325,6 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
|||||||
return r, false, nil
|
return r, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
|
|
||||||
fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"}
|
|
||||||
fetchOptions.Force = true
|
|
||||||
pullOptions.Force = true
|
|
||||||
|
|
||||||
if token != "" {
|
|
||||||
auth := &http.BasicAuth{
|
|
||||||
Username: "token",
|
|
||||||
Password: token,
|
|
||||||
}
|
|
||||||
fetchOptions.Auth = auth
|
|
||||||
pullOptions.Auth = auth
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchOptions, pullOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// staleRefreshErr reports why a failed refresh must abort: the resolve and
|
|
||||||
// checkout that follow are local and succeed on a cancelled context, which
|
|
||||||
// would hand back the cached revision as if it were fresh.
|
|
||||||
func staleRefreshErr(ctx context.Context, err error) error {
|
|
||||||
if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGitCloneExecutor creates an executor to clone git repos
|
// NewGitCloneExecutor creates an executor to clone git repos
|
||||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -373,18 +346,21 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
|
|
||||||
isOfflineMode := input.OfflineMode
|
isOfflineMode := input.OfflineMode
|
||||||
|
|
||||||
// fetch latest changes
|
fetchOptions := git.FetchOptions{
|
||||||
fetchOptions, pullOptions := gitOptions(input.Token)
|
RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD", "refs/heads/*:refs/remotes/origin/*"},
|
||||||
|
Force: true,
|
||||||
if input.InsecureSkipTLS { // For Gitea
|
InsecureSkipTLS: input.InsecureSkipTLS,
|
||||||
fetchOptions.InsecureSkipTLS = true
|
}
|
||||||
pullOptions.InsecureSkipTLS = true
|
if input.Token != "" {
|
||||||
|
fetchOptions.Auth = &http.BasicAuth{
|
||||||
|
Username: "token",
|
||||||
|
Password: input.Token,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||||
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||||
shallow := isShallow(r)
|
if isShallow(r) {
|
||||||
if shallow {
|
|
||||||
fetchOptions.Depth = 1
|
fetchOptions.Depth = 1
|
||||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||||
@@ -392,7 +368,9 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
||||||
_, _, present := refRevision(r, input.Ref)
|
// TODO: revalidate a mutable ref with a conditional archive request instead, once every
|
||||||
|
// supported Gitea sends an ETag for them: https://github.com/go-gitea/gitea/pull/39289
|
||||||
|
_, present := refRevision(r, input.Ref)
|
||||||
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
|
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
|
||||||
|
|
||||||
if refresh {
|
if refresh {
|
||||||
@@ -415,7 +393,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rev, refType, _ := refRevision(r, input.Ref)
|
rev, _ = refRevision(r, input.Ref)
|
||||||
|
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
@@ -427,47 +405,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the hash resolved doesn't match the ref provided in a workflow then we're
|
|
||||||
// using a branch or tag ref, not a sha
|
|
||||||
//
|
|
||||||
// Repos on disk point to commit hashes, and need to checkout input.Ref before
|
|
||||||
// we try and pull down any changes
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
|
||||||
logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes")
|
|
||||||
sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
|
|
||||||
if err = w.Checkout(&git.CheckoutOptions{
|
|
||||||
Branch: sourceRef,
|
|
||||||
Force: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to checkout %s: %v", sourceRef, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reusedMsg := ""
|
reusedMsg := ""
|
||||||
|
if isOfflineMode && reused {
|
||||||
switch {
|
|
||||||
case refresh && !shallow:
|
|
||||||
// In shallow mode the depth-limited fetch above already advanced the ref.
|
|
||||||
if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
||||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
|
||||||
}
|
|
||||||
if err := staleRefreshErr(ctx, err); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case isOfflineMode && reused:
|
|
||||||
reusedMsg = " (reused in offline mode)"
|
reusedMsg = " (reused in offline mode)"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
||||||
|
|
||||||
if hash.String() != input.Ref && refType == "branch" {
|
|
||||||
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")
|
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err = w.Checkout(&git.CheckoutOptions{
|
if err = w.Checkout(&git.CheckoutOptions{
|
||||||
Hash: *hash,
|
Hash: *hash,
|
||||||
Force: true,
|
Force: true,
|
||||||
@@ -476,14 +420,6 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = w.Reset(&git.ResetOptions{
|
|
||||||
Mode: git.HardReset,
|
|
||||||
Commit: *hash,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debugf("Checked out %s", input.Ref)
|
logger.Debugf("Checked out %s", input.Ref)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -502,10 +438,11 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, refName := range []plumbing.ReferenceName{
|
refNames := []plumbing.ReferenceName{plumbing.NewBranchReferenceName(input.Ref), plumbing.NewTagReferenceName(input.Ref)}
|
||||||
plumbing.NewBranchReferenceName(input.Ref),
|
if input.Ref == plumbing.HEAD.String() || strings.HasPrefix(input.Ref, "refs/") {
|
||||||
plumbing.NewTagReferenceName(input.Ref),
|
refNames = []plumbing.ReferenceName{plumbing.ReferenceName(input.Ref)} // HEAD is the remote's default branch
|
||||||
} {
|
}
|
||||||
|
for _, refName := range refNames {
|
||||||
shallowOpts := opts
|
shallowOpts := opts
|
||||||
shallowOpts.Depth = input.Depth
|
shallowOpts.Depth = input.Depth
|
||||||
shallowOpts.SingleBranch = true
|
shallowOpts.SingleBranch = true
|
||||||
@@ -562,22 +499,22 @@ func pinnedRefSpec(sha string) config.RefSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refRevision picks the revision to check out and reports whether it resolves locally.
|
// refRevision picks the revision to check out and reports whether it resolves locally.
|
||||||
func refRevision(r *git.Repository, ref string) (plumbing.Revision, string, bool) {
|
func refRevision(r *git.Repository, ref string) (plumbing.Revision, bool) {
|
||||||
if plumbing.IsHash(ref) {
|
if plumbing.IsHash(ref) {
|
||||||
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
|
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
|
||||||
_, err := r.CommitObject(plumbing.NewHash(ref))
|
_, err := r.CommitObject(plumbing.NewHash(ref))
|
||||||
return plumbing.Revision(ref), "sha", err == nil
|
return plumbing.Revision(ref), err == nil
|
||||||
}
|
}
|
||||||
if _, err := r.Tag(ref); err == nil {
|
if _, err := r.Tag(ref); err == nil {
|
||||||
return plumbing.Revision(path.Join("refs", "tags", ref)), "tag", true
|
return plumbing.Revision(path.Join("refs", "tags", ref)), true
|
||||||
}
|
}
|
||||||
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
|
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
|
||||||
if _, err := r.Reference(remoteRef, false); err == nil {
|
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||||
return plumbing.Revision(remoteRef), "branch", true
|
return plumbing.Revision(remoteRef), true
|
||||||
}
|
}
|
||||||
rev := plumbing.Revision(ref)
|
rev := plumbing.Revision(ref)
|
||||||
_, err := r.ResolveRevision(rev)
|
_, err := r.ResolveRevision(rev)
|
||||||
return rev, "sha", err == nil
|
return rev, err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||||
|
|||||||
+34
-23
@@ -6,7 +6,6 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -524,30 +523,51 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorColdCloneSkipsRefresh(t *testing.T) {
|
func TestGitCloneExecutorTransportSessions(t *testing.T) {
|
||||||
remoteDir := t.TempDir()
|
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
|
||||||
workDir := t.TempDir()
|
workDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
require.NoError(t, gitCmd("init", "--initial-branch=main", workDir))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "-a", "v2", "-m", "v2"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
|
||||||
|
|
||||||
for name, tt := range map[string]struct {
|
for name, tt := range map[string]struct {
|
||||||
Ref string
|
Ref string
|
||||||
Depth int
|
Depth int
|
||||||
}{
|
}{
|
||||||
"shallow branch": {"main", 1},
|
"shallow branch": {"main", 1},
|
||||||
"full clone tag": {"v1", 0},
|
"full clone branch": {"main", 0},
|
||||||
|
"full lightweight tag": {"v1", 0},
|
||||||
|
"full annotated tag": {"v2", 0},
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
counter := installCountingTransport(t)
|
counter := installCountingTransport(t)
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
dir := t.TempDir()
|
||||||
URL: remoteDir, Ref: tt.Ref, Dir: t.TempDir(), Depth: tt.Depth,
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
})(t.Context()))
|
URL: workDir, Ref: tt.Ref, Dir: dir, Depth: tt.Depth,
|
||||||
assert.Equal(t, int64(1), counter.sessions.Load())
|
})
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Swap(0), "cold clone")
|
||||||
|
assert.Equal(t, gitRevParse(t, workDir, tt.Ref+"^{commit}"), gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Swap(0), "unchanged warm cache")
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.yml"), []byte(name), 0o644))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "add", "action.yml"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", name))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "v1"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "-a", "v2", "-m", "v2"))
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("staged"), 0o644))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "add", "action.yml"))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("unstaged"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
assert.Equal(t, int64(1), counter.sessions.Load(), "updated warm cache")
|
||||||
|
assert.Equal(t, gitRevParse(t, workDir, "HEAD"), gitRevParse(t, dir, "HEAD"))
|
||||||
|
status, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, string(status))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -736,12 +756,3 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
|||||||
t.Fatal("fetch ignored context cancellation")
|
t.Fatal("fetch ignored context cancellation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStaleRefreshErr(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
require.NoError(t, staleRefreshErr(ctx, errors.New("remote hung up")))
|
|
||||||
|
|
||||||
cancel()
|
|
||||||
require.ErrorIs(t, staleRefreshErr(ctx, errors.New("remote hung up")), context.Canceled)
|
|
||||||
require.NoError(t, staleRefreshErr(ctx, gogit.NoErrAlreadyUpToDate))
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -77,13 +81,20 @@ var ErrContainerNotFound = errors.New("does not exist")
|
|||||||
|
|
||||||
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
|
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
|
||||||
type DockerProxy struct {
|
type DockerProxy struct {
|
||||||
Socket string
|
Socket string
|
||||||
close func(context.Context) error
|
close func(context.Context) error
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
|
mounts atomic.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DockerProxy) SetMounts(mounts map[string]string) {
|
||||||
|
p.mounts.Store(mounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close removes what the job created through the socket, then stops serving it.
|
|
||||||
func (p *DockerProxy) Close(ctx context.Context) error {
|
func (p *DockerProxy) Close(ctx context.Context) error {
|
||||||
return p.close(ctx)
|
p.closeOnce.Do(func() { p.closeErr = p.close(ctx) })
|
||||||
|
return p.closeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info is a snapshot of a container, as of one inspect.
|
// Info is a snapshot of a container, as of one inspect.
|
||||||
@@ -98,6 +109,16 @@ type Info struct {
|
|||||||
Mounts map[string]string // container path to its source on the daemon
|
Mounts map[string]string // container path to its source on the daemon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DaemonPath maps target to its daemon path through the nearest mount with a source, empty without one.
|
||||||
|
func (info *Info) DaemonPath(target string) string {
|
||||||
|
for dir := target; dir != "/" && dir != "."; dir = path.Dir(dir) {
|
||||||
|
if source := info.Mounts[dir]; source != "" {
|
||||||
|
return path.Join(source, strings.TrimPrefix(target, dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Container for managing docker run containers
|
// Container for managing docker run containers
|
||||||
type Container interface {
|
type Container interface {
|
||||||
Create(capAdd, capDrop []string) common.Executor
|
Create(capAdd, capDrop []string) common.Executor
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts.go with:
|
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
||||||
|
// * jsontext in place of json.Compact for the seccomp profile
|
||||||
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
||||||
//
|
//
|
||||||
// docker/cli is licensed under the Apache License, Version 2.0.
|
// docker/cli is licensed under the Apache License, Version 2.0.
|
||||||
@@ -152,6 +153,7 @@ type containerOptions struct {
|
|||||||
runtime string
|
runtime string
|
||||||
autoRemove bool
|
autoRemove bool
|
||||||
init bool
|
init bool
|
||||||
|
umask opts.UmaskOpt
|
||||||
annotations *opts.MapOpts
|
annotations *opts.MapOpts
|
||||||
|
|
||||||
Image string
|
Image string
|
||||||
@@ -326,6 +328,9 @@ func addFlags(flags *pflag.FlagSet) *containerOptions {
|
|||||||
flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm")
|
flags.Var(&copts.shmSize, "shm-size", "Size of /dev/shm")
|
||||||
flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use")
|
flags.StringVar(&copts.utsMode, "uts", "", "UTS namespace to use")
|
||||||
flags.StringVar(&copts.runtime, "runtime", "", "Runtime to use for this container")
|
flags.StringVar(&copts.runtime, "runtime", "", "Runtime to use for this container")
|
||||||
|
flags.Var(&copts.umask, "umask", "Set umask for the container")
|
||||||
|
flags.SetAnnotation("umask", "version", []string{"1.56"})
|
||||||
|
flags.SetAnnotation("umask", "ostype", []string{"linux"})
|
||||||
|
|
||||||
flags.BoolVar(&copts.init, "init", false, "Run an init inside the container that forwards signals and reaps processes")
|
flags.BoolVar(&copts.init, "init", false, "Run an init inside the container that forwards signals and reaps processes")
|
||||||
flags.SetAnnotation("init", "version", []string{"1.25"})
|
flags.SetAnnotation("init", "version", []string{"1.25"})
|
||||||
@@ -721,6 +726,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
MaskedPaths: maskedPaths,
|
MaskedPaths: maskedPaths,
|
||||||
ReadonlyPaths: readonlyPaths,
|
ReadonlyPaths: readonlyPaths,
|
||||||
Annotations: copts.annotations.GetAll(),
|
Annotations: copts.annotations.GetAll(),
|
||||||
|
Umask: copts.umask.Value(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() {
|
if copts.autoRemove && !hostConfig.RestartPolicy.IsNone() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/dfc4efb1e2ab8c06d70d2a1366ad448d2f917e90/cli/command/container/opts_test.go with:
|
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts_test.go with:
|
||||||
// * appended with license information
|
// * appended with license information
|
||||||
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter
|
// * added tests for the locally changed parseDevice, validateDevice and invalidParameter
|
||||||
//
|
//
|
||||||
@@ -167,6 +167,19 @@ func TestParseRunLinks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRunWithoutUmask(t *testing.T) {
|
||||||
|
_, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"})
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Assert(t, hostConfig.Umask == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRunUmask(t *testing.T) {
|
||||||
|
_, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"})
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Assert(t, hostConfig.Umask != nil)
|
||||||
|
assert.Equal(t, uint32(0o22), *hostConfig.Umask)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRunAttach(t *testing.T) {
|
func TestParseRunAttach(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,17 +35,17 @@ const (
|
|||||||
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
||||||
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
||||||
// while this runs cannot lose the network it has created but not yet attached a container to.
|
// while this runs cannot lose the network it has created but not yet attached a container to.
|
||||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
func RemoveOrphanNetworks(ctx context.Context, runnerUUID, detach string, createdBefore time.Time) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore)
|
return removeOrphanNetworks(ctx, cli, runnerUUID, detach, createdBefore)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error {
|
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID, detach string, createdBefore time.Time) error {
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
||||||
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||||
})
|
})
|
||||||
@@ -51,6 +54,7 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
|
|||||||
}
|
}
|
||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
|
networks:
|
||||||
for _, n := range networks.Items {
|
for _, n := range networks.Items {
|
||||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,9 +63,18 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID
|
|||||||
}
|
}
|
||||||
// the emptiness check, not the label, is what keeps a live job of another process
|
// the emptiness check, not the label, is what keeps a live job of another process
|
||||||
// sharing this registration safe
|
// sharing this registration safe
|
||||||
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) {
|
if len(result.Network.Containers) > 1 || result.Network.Created.After(createdBefore) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
for containerID := range result.Network.Containers {
|
||||||
|
if detach == "" || !strings.HasPrefix(containerID, detach) {
|
||||||
|
continue networks
|
||||||
|
}
|
||||||
|
if err := disconnectNetwork(ctx, cli, n.ID, containerID); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to disconnect container from network %s: %w", n.Name, err))
|
||||||
|
continue networks
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||||
continue
|
continue
|
||||||
@@ -134,6 +147,50 @@ func isAddressPoolExhausted(err error) bool {
|
|||||||
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkConnectExecutor(networkName, container string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
if err := connectNetwork(ctx, cli, networkName, container); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("cannot connect %s to network %s, so jobs on it cannot reach the cache: %v", container, networkName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
|
||||||
|
_, err := cli.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
|
||||||
|
Container: container,
|
||||||
|
EndpointConfig: &network.EndpointSettings{Aliases: []string{container}},
|
||||||
|
})
|
||||||
|
if err != nil && (strings.Contains(err.Error(), "already exists in network") || strings.Contains(err.Error(), "already connected")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkDisconnectExecutor(networkName, container string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
return disconnectNetwork(ctx, cli, networkName, container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnectNetwork(ctx context.Context, cli client.APIClient, networkName, container string) error {
|
||||||
|
_, err := cli.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{Container: container, Force: true})
|
||||||
|
if cerrdefs.IsNotFound(err) || (err != nil && strings.Contains(err.Error(), "is not connected")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
@@ -172,3 +229,49 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsolatedCacheContainer returns the container holding addr, when jobs on jobNetwork cannot reach it there.
|
||||||
|
func IsolatedCacheContainer(ctx context.Context, addr netip.Addr, jobNetwork string) (string, error) {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
return isolatedCacheContainer(ctx, cli, addr, jobNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isolatedCacheContainer(ctx context.Context, cli client.APIClient, addr netip.Addr, jobNetwork string) (string, error) {
|
||||||
|
if jobNetwork == "host" || strings.HasPrefix(jobNetwork, "container:") {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var holderID, holderContainer string
|
||||||
|
for _, summary := range containers.Items {
|
||||||
|
if summary.NetworkSettings == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, endpoint := range summary.NetworkSettings.Networks {
|
||||||
|
if endpoint != nil && (endpoint.IPAddress == addr || endpoint.GlobalIPv6Address == addr) {
|
||||||
|
holderID = endpoint.NetworkID
|
||||||
|
holderContainer = summary.ID[:12]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if holderID == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
holder, err := cli.NetworkInspect(ctx, holderID, client.NetworkInspectOptions{})
|
||||||
|
if err != nil || holder.Network.Driver != "bridge" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if jobNetwork != "" {
|
||||||
|
job, err := cli.NetworkInspect(ctx, jobNetwork, client.NetworkInspectOptions{})
|
||||||
|
if err != nil || job.Network.ID == holderID {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return holderContainer, nil
|
||||||
|
}
|
||||||
@@ -6,25 +6,95 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestIsolatedCacheContainer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
|
||||||
|
ID: "0123456789abcdef",
|
||||||
|
NetworkSettings: &container.NetworkSettingsSummary{Networks: map[string]*network.EndpointSettings{
|
||||||
|
"compose": {NetworkID: "c0ffee", IPAddress: cache},
|
||||||
|
"lan": {NetworkID: "beef", IPAddress: lan},
|
||||||
|
}},
|
||||||
|
}}}, nil)
|
||||||
|
for ref, inspected := range map[string]network.Network{
|
||||||
|
"c0ffee": {ID: "c0ffee", Name: "compose", Driver: "bridge"},
|
||||||
|
"beef": {ID: "beef", Name: "lan", Driver: "macvlan"},
|
||||||
|
"c0f": {ID: "c0ffee"},
|
||||||
|
"jobs": {ID: "f00d"},
|
||||||
|
} {
|
||||||
|
client.On("NetworkInspect", ctx, ref, mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: inspected}}, nil)
|
||||||
|
}
|
||||||
|
check := func(addr netip.Addr, jobNetwork, want string) {
|
||||||
|
got, err := isolatedCacheContainer(ctx, client, addr, jobNetwork)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, got, jobNetwork)
|
||||||
|
}
|
||||||
|
check(cache, "", "0123456789ab")
|
||||||
|
check(cache, "jobs", "0123456789ab")
|
||||||
|
check(cache, "c0f", "")
|
||||||
|
check(cache, "host", "")
|
||||||
|
check(lan, "", "")
|
||||||
|
check(netip.MustParseAddr("10.0.0.5"), "", "")
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsAddressPoolExhausted(t *testing.T) {
|
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||||
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||||
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||||
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
|
func (m *mockDockerClient) NetworkConnect(ctx context.Context, id string, opts mobyclient.NetworkConnectOptions) (mobyclient.NetworkConnectResult, error) {
|
||||||
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||||
// test on its own, since testify has no expectation to match it against.
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkDisconnect(ctx context.Context, id string, opts mobyclient.NetworkDisconnectOptions) (mobyclient.NetworkDisconnectResult, error) {
|
||||||
|
return mobyclient.NetworkDisconnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectAndDisconnectNetwork(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
connect := func(answer error) error {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
|
||||||
|
Container: "0123456789ab",
|
||||||
|
EndpointConfig: &network.EndpointSettings{Aliases: []string{"0123456789ab"}},
|
||||||
|
}).Return(answer).Once()
|
||||||
|
return connectNetwork(ctx, client, "jobs", "0123456789ab")
|
||||||
|
}
|
||||||
|
require.NoError(t, connect(nil))
|
||||||
|
require.NoError(t, connect(errors.New("endpoint with name runner already exists in network jobs")))
|
||||||
|
require.NoError(t, connect(errors.New("container runner already connected to network jobs")))
|
||||||
|
require.ErrorIs(t, connect(context.Canceled), context.Canceled)
|
||||||
|
|
||||||
|
disconnect := func(answer error) error {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkDisconnect", ctx, "jobs", mobyclient.NetworkDisconnectOptions{
|
||||||
|
Container: "0123456789ab", Force: true,
|
||||||
|
}).Return(answer).Once()
|
||||||
|
return disconnectNetwork(ctx, client, "jobs", "0123456789ab")
|
||||||
|
}
|
||||||
|
require.NoError(t, disconnect(cerrdefs.ErrNotFound))
|
||||||
|
require.NoError(t, disconnect(errors.New("container runner is not connected to the network jobs")))
|
||||||
|
require.ErrorIs(t, disconnect(context.Canceled), context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of this runner's networks, only the ones nothing but its own cache container is attached to
|
||||||
|
// and old enough to predate any job now starting are the runner's to reclaim. An unexpected
|
||||||
|
// NetworkRemove fails the test on its own, since testify has no expectation to match it against.
|
||||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
@@ -35,16 +105,29 @@ func TestRemoveOrphanNetworks(t *testing.T) {
|
|||||||
{ID: "orphan"},
|
{ID: "orphan"},
|
||||||
{ID: "busy"},
|
{ID: "busy"},
|
||||||
{ID: "starting"},
|
{ID: "starting"},
|
||||||
|
{ID: "cached"},
|
||||||
|
{ID: "shared"},
|
||||||
}}, nil)
|
}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||||
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{
|
||||||
|
Network: network.Network{Created: cutoff.Add(time.Second)},
|
||||||
|
Containers: map[string]network.EndpointResource{"0123456789abcdef": {}},
|
||||||
|
}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "cached", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}}}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "shared", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}, "job": {}}}}, nil)
|
||||||
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||||
|
client.On("NetworkDisconnect", ctx, "cached", mobyclient.NetworkDisconnectOptions{Container: "0123456789abcdef", Force: true}).
|
||||||
|
Return(nil).Once()
|
||||||
|
client.On("NetworkRemove", ctx, "cached", mobyclient.NetworkRemoveOptions{}).
|
||||||
|
Return(mobyclient.NetworkRemoveResult{}, nil).Once()
|
||||||
|
|
||||||
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff))
|
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", "0123456789ab", cutoff))
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
+426
-115
@@ -6,20 +6,24 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -35,64 +39,116 @@ import (
|
|||||||
const (
|
const (
|
||||||
jobLabel = "com.gitea.runner.job"
|
jobLabel = "com.gitea.runner.job"
|
||||||
maxCreateBody = 8 << 20
|
maxCreateBody = 8 << 20
|
||||||
|
|
||||||
|
dockerProxyProbeTimeout = 5 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
|
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
|
||||||
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
||||||
proxyProbe struct {
|
|
||||||
sync.Mutex
|
|
||||||
decided bool
|
|
||||||
dir string
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DockerProxyDir returns where job proxy sockets live, "" while undecided or when the daemon cannot open the runner's files.
|
func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
|
||||||
func DockerProxyDir(ctx context.Context) string {
|
if host := os.Getenv("DOCKER_HOST"); runtime.GOOS != "linux" || host != "" && !strings.HasPrefix(host, "unix://") {
|
||||||
proxyProbe.Lock()
|
return nil
|
||||||
defer proxyProbe.Unlock()
|
|
||||||
if proxyProbe.decided {
|
|
||||||
return proxyProbe.dir
|
|
||||||
}
|
}
|
||||||
dir := filepath.Join(os.TempDir(), "gitea-runner-docker")
|
probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
|
||||||
ok, err := daemonSeesDir(ctx, dir)
|
defer cancel()
|
||||||
|
cli, err := GetDockerClient(probeCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Debugf("docker proxy probe postponed: %v", err)
|
return nil
|
||||||
return ""
|
|
||||||
}
|
|
||||||
proxyProbe.decided = true
|
|
||||||
if ok {
|
|
||||||
proxyProbe.dir = dir
|
|
||||||
} else {
|
|
||||||
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's filesystem, jobs get the daemon socket directly")
|
|
||||||
}
|
|
||||||
return proxyProbe.dir
|
|
||||||
}
|
|
||||||
|
|
||||||
func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
|
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
probe := filepath.Join(dir, "probe")
|
|
||||||
if err := os.WriteFile(probe, nil, 0o600); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
cli, err := GetDockerClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
daemonSocket, ok := strings.CutPrefix(cli.DaemonHost(), "unix://")
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir, daemonDir := runnerContainerWorkdir(probeCtx, cli)
|
||||||
|
seen := daemonDir != ""
|
||||||
|
if !seen {
|
||||||
|
if dir, err = filepath.Abs(os.TempDir()); err == nil {
|
||||||
|
daemonDir = dir
|
||||||
|
seen, err = daemonSeesDir(probeCtx, cli, dir, daemonDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !seen {
|
||||||
|
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proxy, err := StartDockerProxy(daemonSocket, dir, job)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proxy.Socket = daemonDir + strings.TrimPrefix(proxy.Socket, dir)
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
// runnerContainerWorkdir looks the runner's container up by hostname to find the daemon's path to its working directory.
|
||||||
|
func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir, daemonDir string) {
|
||||||
|
workdir, err := os.Getwd()
|
||||||
|
hostname, hostnameErr := os.Hostname()
|
||||||
|
if err != nil || hostnameErr != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
self, err := cli.ContainerInspect(ctx, hostname, client.ContainerInspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if daemonDir = containerInfoFromInspect(self.Container).DaemonPath(workdir); daemonDir == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if seen, _ := containerSeesMarker(ctx, cli, workdir, self.Container.ID, workdir); !seen { // the hostname may name another container
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return workdir, daemonDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerSeesMarker(ctx context.Context, cli client.APIClient, dir, id, containerDir string) (bool, error) {
|
||||||
|
marker, err := os.CreateTemp(dir, "gitea-runner-probe-")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.Remove(marker.Name()); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("removing the docker proxy probe marker failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := marker.Close(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_, err = cli.ContainerStatPath(ctx, id, client.ContainerStatPathOptions{Path: path.Join(containerDir, filepath.Base(marker.Name()))})
|
||||||
|
if cerrdefs.IsNotFound(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir by their path in daemonDir,
|
||||||
|
// which is what a job's proxy socket mounted from there needs.
|
||||||
|
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir string) (bool, error) {
|
||||||
images, err := cli.ImageList(ctx, client.ImageListOptions{})
|
images, err := cli.ImageList(ctx, client.ImageListOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if len(images.Items) == 0 {
|
if len(images.Items) == 0 {
|
||||||
return false, errors.New("no image to probe with yet")
|
return false, errors.New("no image available for the docker proxy probe")
|
||||||
}
|
}
|
||||||
// creating validates that a bind source exists on the daemon's side, nothing is started
|
|
||||||
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
||||||
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
|
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
|
||||||
HostConfig: &container.HostConfig{Mounts: []mount.Mount{{Type: mount.TypeBind, Source: probe, Target: "/gitea-runner-probe"}}},
|
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
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
if cerrdefs.IsInvalidArgument(err) {
|
if cerrdefs.IsInvalidArgument(err) {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -100,24 +156,38 @@ func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
_, err = cli.ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true})
|
seen, err := containerSeesMarker(ctx, cli, dir, created.ID, "/gitea-runner-probe")
|
||||||
return true, err
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if _, removeErr := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); removeErr != nil {
|
||||||
|
return false, fmt.Errorf("removing the docker proxy probe container failed: %w", removeErr)
|
||||||
|
}
|
||||||
|
return seen, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
info, err := os.Stat(daemonSocket)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
digest := sha256.Sum256([]byte(job))
|
|
||||||
socket := filepath.Join(dir, hex.EncodeToString(digest[:8])+".sock")
|
|
||||||
_ = os.Remove(socket)
|
|
||||||
listener, err := net.Listen("unix", socket)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if info, err := os.Stat(daemonSocket); err == nil {
|
if info.Mode()&os.ModeSocket == 0 {
|
||||||
_ = os.Chmod(socket, info.Mode().Perm())
|
return nil, errors.New("docker daemon path is not a Unix socket")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
instance, err := os.MkdirTemp(dir, "p-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
socket := filepath.Join(instance, "docker.sock")
|
||||||
|
listener, err := net.Listen("unix", socket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Join(err, os.RemoveAll(instance))
|
||||||
|
}
|
||||||
|
if err := copyDockerSocketPermissions(daemonSocket, socket, info); err != nil {
|
||||||
|
return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance))
|
||||||
}
|
}
|
||||||
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
|
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
|
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
|
||||||
@@ -130,61 +200,110 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
|||||||
},
|
},
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
}
|
}
|
||||||
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
proxy := &DockerProxy{Socket: socket}
|
||||||
switch {
|
streams, cancelStreams := context.WithCancel(context.Background())
|
||||||
case r.Method != http.MethodPost:
|
creates, cancelCreates := context.WithCancel(context.Background())
|
||||||
case createPath.MatchString(r.URL.Path):
|
var admission sync.Mutex
|
||||||
if err := addLabel(r, job); err != nil {
|
var handlers sync.WaitGroup
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, ConnContext: func(ctx context.Context, conn net.Conn) context.Context {
|
||||||
|
return context.WithValue(ctx, dockerProxyConnKey{}, conn)
|
||||||
|
}, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admission.Lock()
|
||||||
|
if streams.Err() != nil {
|
||||||
|
admission.Unlock()
|
||||||
|
http.Error(w, "docker proxy is closing", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlers.Add(1)
|
||||||
|
admission.Unlock()
|
||||||
|
defer handlers.Done()
|
||||||
|
creating := r.Method == http.MethodPost && createPath.MatchString(r.URL.Path)
|
||||||
|
parent, lifetime := r.Context(), streams
|
||||||
|
if creating {
|
||||||
|
parent, lifetime = context.WithoutCancel(parent), creates
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(parent)
|
||||||
|
defer cancel()
|
||||||
|
stop := context.AfterFunc(lifetime, func() {
|
||||||
|
cancel()
|
||||||
|
if !creating {
|
||||||
|
if conn, ok := parent.Value(dockerProxyConnKey{}).(net.Conn); ok {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer stop()
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
if creating {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
|
||||||
|
mounts, _ := proxy.mounts.Load().(map[string]string)
|
||||||
|
if err := rewriteCreate(r, job, mounts); err != nil {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
|
||||||
|
status = http.StatusRequestEntityTooLarge
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case rawStreamPath.MatchString(r.URL.Path):
|
} else if r.Method == http.MethodPost && rawStreamPath.MatchString(r.URL.Path) {
|
||||||
tunnel(w, r, dial)
|
tunnel(w, r, dial, forward)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
forward.ServeHTTP(w, r)
|
forward.ServeHTTP(w, r)
|
||||||
})}
|
})}
|
||||||
go func() { _ = server.Serve(listener) }()
|
served := make(chan struct{})
|
||||||
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error {
|
go func() {
|
||||||
err := removeJobResources(ctx, job)
|
defer close(served)
|
||||||
_ = server.Close()
|
_ = server.Serve(listener)
|
||||||
|
}()
|
||||||
|
proxy.close = func(ctx context.Context) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
admission.Lock()
|
||||||
|
listenerErr := listener.Close()
|
||||||
|
cancelStreams()
|
||||||
|
admission.Unlock()
|
||||||
|
<-served
|
||||||
|
shutdownErr := server.Shutdown(ctx)
|
||||||
|
cancelCreates()
|
||||||
|
serverErr := server.Close()
|
||||||
|
handlers.Wait()
|
||||||
transport.CloseIdleConnections()
|
transport.CloseIdleConnections()
|
||||||
_ = os.Remove(socket)
|
return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
|
||||||
return err
|
}
|
||||||
}}, nil
|
return proxy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func addLabel(r *http.Request, job string) error {
|
func rewriteCreate(r *http.Request, job string, mounts map[string]string) error {
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxCreateBody+1))
|
body, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(body) > maxCreateBody {
|
|
||||||
return errors.New("create request too large")
|
|
||||||
}
|
|
||||||
if len(bytes.TrimSpace(body)) == 0 {
|
if len(bytes.TrimSpace(body)) == 0 {
|
||||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
body = []byte("{}")
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
var fields map[string]json.RawMessage
|
var fields map[string]json.RawMessage
|
||||||
|
var config struct{ Labels map[string]string }
|
||||||
if err := json.Unmarshal(body, &fields); err != nil {
|
if err := json.Unmarshal(body, &fields); err != nil {
|
||||||
return fmt.Errorf("invalid create request: %w", err)
|
return fmt.Errorf("invalid create request: %w", err)
|
||||||
}
|
}
|
||||||
key := "Labels"
|
if err := json.Unmarshal(body, &config); err != nil {
|
||||||
for name := range fields {
|
return fmt.Errorf("invalid create labels: %w", err)
|
||||||
if strings.EqualFold(name, key) {
|
|
||||||
key = name
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
labels := map[string]string{}
|
if fields == nil {
|
||||||
if raw := fields[key]; len(raw) > 0 && string(raw) != "null" {
|
fields = make(map[string]json.RawMessage)
|
||||||
if err := json.Unmarshal(raw, &labels); err != nil {
|
|
||||||
return fmt.Errorf("invalid create request: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
labels[jobLabel] = job
|
if len(mounts) > 0 && !hasAmbiguousFields(body) {
|
||||||
if fields[key], err = json.Marshal(labels); err != nil {
|
translateBinds(fields, createPath.FindStringSubmatch(r.URL.Path)[2], mounts)
|
||||||
|
}
|
||||||
|
maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
|
||||||
|
return strings.EqualFold(name, "Labels")
|
||||||
|
})
|
||||||
|
if config.Labels == nil {
|
||||||
|
config.Labels = make(map[string]string)
|
||||||
|
}
|
||||||
|
config.Labels[jobLabel] = job
|
||||||
|
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if body, err = json.Marshal(fields); err != nil {
|
if body, err = json.Marshal(fields); err != nil {
|
||||||
@@ -196,44 +315,241 @@ func addLabel(r *http.Request, job string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
|
func translateBinds(fields map[string]json.RawMessage, kind string, mounts map[string]string) {
|
||||||
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error)) {
|
cleanedSource := func(source string) string {
|
||||||
hijacker, ok := w.(http.Hijacker)
|
cleaned := path.Clean(source)
|
||||||
if !ok {
|
if target := jobMount(cleaned, mounts); mounts[target] != "" {
|
||||||
http.Error(w, "connection cannot be hijacked", http.StatusInternalServerError)
|
return mounts[target] + cleaned[len(target):]
|
||||||
return
|
}
|
||||||
|
return source
|
||||||
}
|
}
|
||||||
|
spelledSource := func(source string) string { // dockerd checks these as spelled
|
||||||
|
target := jobMount(path.Clean(source), mounts)
|
||||||
|
if rest, spelled := strings.CutPrefix(source, target); mounts[target] != "" && spelled && (rest == "" || rest[0] == '/') && filepath.IsLocal("."+rest) {
|
||||||
|
return mounts[target] + rest
|
||||||
|
}
|
||||||
|
return cleanedSource(source)
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case "volumes":
|
||||||
|
var driver string
|
||||||
|
var options map[string]any
|
||||||
|
decodeField(fields, "Driver", &driver)
|
||||||
|
if key := decodeField(fields, "DriverOpts", &options); options != nil {
|
||||||
|
translateDevice(driver, options, spelledSource)
|
||||||
|
if encoded, err := json.Marshal(options); err == nil {
|
||||||
|
fields[key] = encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "containers":
|
||||||
|
var hostConfig map[string]any
|
||||||
|
key := decodeField(fields, "HostConfig", &hostConfig)
|
||||||
|
binds, _ := field(hostConfig, "Binds").([]any)
|
||||||
|
for i, bind := range binds {
|
||||||
|
bind, _ := bind.(string)
|
||||||
|
source, target, _ := strings.Cut(bind, ":")
|
||||||
|
if translated := cleanedSource(source); strings.HasPrefix(target, "/") && !strings.Contains(translated, ":") {
|
||||||
|
binds[i] = translated + ":" + target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
specs, _ := field(hostConfig, "Mounts").([]any)
|
||||||
|
for _, spec := range specs {
|
||||||
|
spec, _ := spec.(map[string]any)
|
||||||
|
switch field(spec, "Type") {
|
||||||
|
case "bind":
|
||||||
|
if source, ok := field(spec, "Source").(string); ok {
|
||||||
|
spec["Source"] = spelledSource(source)
|
||||||
|
}
|
||||||
|
case "volume":
|
||||||
|
volumeOptions, _ := field(spec, "VolumeOptions").(map[string]any)
|
||||||
|
driverConfig, _ := field(volumeOptions, "DriverConfig").(map[string]any)
|
||||||
|
translateDevice(field(driverConfig, "Name"), field(driverConfig, "Options"), spelledSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if encoded, err := json.Marshal(hostConfig); err == nil && hostConfig != nil {
|
||||||
|
fields[key] = encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeField(fields map[string]json.RawMessage, name string, value any) string {
|
||||||
|
for key, raw := range fields {
|
||||||
|
if strings.EqualFold(key, name) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.UseNumber()
|
||||||
|
_ = decoder.Decode(value) // wrong types stay unset for dockerd to reject
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func translateDevice(driver, options any, translate func(string) string) {
|
||||||
|
optionMap, _ := options.(map[string]any)
|
||||||
|
device, ok := optionMap["device"].(string)
|
||||||
|
flags, _ := optionMap["o"].(string)
|
||||||
|
tokens := strings.Split(flags, ",")
|
||||||
|
local := driver == nil || driver == "" || driver == "local"
|
||||||
|
if ok && local && (slices.Contains(tokens, "bind") || slices.Contains(tokens, "rbind")) && !slices.Contains(tokens, "remount") {
|
||||||
|
optionMap["device"] = translate(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// field also renames the matched key to name.
|
||||||
|
func field(object map[string]any, name string) any {
|
||||||
|
for key, value := range object {
|
||||||
|
if strings.EqualFold(key, name) {
|
||||||
|
delete(object, key)
|
||||||
|
object[name] = value
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
requestFields = []string{"hostconfig", "driver", "driveropts"}
|
||||||
|
asciiFolds = strings.NewReplacer("ſ", "s", "K", "k") // the non-ASCII runes strings.EqualFold matches to ASCII letters
|
||||||
|
)
|
||||||
|
|
||||||
|
// dockerd settles repeated names by order, which re-encoding loses.
|
||||||
|
func hasAmbiguousFields(body []byte) bool {
|
||||||
|
type frame struct {
|
||||||
|
names map[string]bool
|
||||||
|
key string
|
||||||
|
expectKey bool
|
||||||
|
nested bool
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
decoder.UseNumber()
|
||||||
|
stack := []*frame{{}}
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return !errors.Is(err, io.EOF)
|
||||||
|
}
|
||||||
|
top := stack[len(stack)-1]
|
||||||
|
if name, ok := token.(string); ok && top.expectKey {
|
||||||
|
top.key, top.expectKey = strings.ToLower(asciiFolds.Replace(name)), false
|
||||||
|
if top.names[top.key] && (top.nested || slices.Contains(requestFields, top.key)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
top.names[top.key] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
top.expectKey = top.names != nil
|
||||||
|
nested := top.nested || len(stack) == 2 && slices.Contains(requestFields, top.key)
|
||||||
|
switch token {
|
||||||
|
case json.Delim('{'):
|
||||||
|
stack = append(stack, &frame{names: map[string]bool{}, expectKey: true, nested: nested})
|
||||||
|
case json.Delim('['):
|
||||||
|
stack = append(stack, &frame{nested: nested})
|
||||||
|
case json.Delim('}'), json.Delim(']'):
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobMount returns "" also for a path already naming a daemon source.
|
||||||
|
func jobMount(source string, mounts map[string]string) string {
|
||||||
|
target := ""
|
||||||
|
for destination, daemonSource := range mounts {
|
||||||
|
if daemonSource != "" && (source == daemonSource || strings.HasPrefix(source, daemonSource+"/")) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(destination) > len(target) && (source == destination || strings.HasPrefix(source, destination+"/")) {
|
||||||
|
target = destination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
type dockerProxyConnKey struct{}
|
||||||
|
|
||||||
|
type dockerProxyResponse struct {
|
||||||
|
response *http.Response
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r dockerProxyResponse) RoundTrip(_ *http.Request) (*http.Response, error) {
|
||||||
|
return r.response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
|
||||||
|
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error), forward *httputil.ReverseProxy) {
|
||||||
upstream, err := dial(r.Context(), "", "")
|
upstream, err := dial(r.Context(), "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer upstream.Close()
|
defer upstream.Close()
|
||||||
|
stop := context.AfterFunc(r.Context(), func() { _ = upstream.Close() })
|
||||||
|
defer stop()
|
||||||
if err := r.Write(upstream); err != nil {
|
if err := r.Write(upstream); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
downstream, buffered, err := hijacker.Hijack()
|
reader := bufio.NewReader(upstream)
|
||||||
|
var response *http.Response
|
||||||
|
for {
|
||||||
|
response, err = http.ReadResponse(reader, r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if response.StatusCode >= 200 || response.StatusCode == http.StatusSwitchingProtocols {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
maps.Copy(w.Header(), response.Header)
|
||||||
|
w.WriteHeader(response.StatusCode)
|
||||||
|
clear(w.Header())
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = upstream.Close()
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}()
|
||||||
|
mediaType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||||
|
if response.StatusCode != http.StatusSwitchingProtocols && (response.StatusCode != http.StatusOK || mediaType != "application/vnd.docker.raw-stream") {
|
||||||
|
ordinary := *forward
|
||||||
|
ordinary.Transport = dockerProxyResponse{response: response}
|
||||||
|
ordinary.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downstream, buffered, err := http.NewResponseController(w).Hijack()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer downstream.Close()
|
defer downstream.Close()
|
||||||
if _, err := io.CopyN(upstream, buffered, int64(buffered.Reader.Buffered())); err != nil {
|
if _, err := fmt.Fprintf(buffered, "%s %s\r\n", response.Proto, response.Status); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
done := make(chan struct{}, 2)
|
if err := response.Header.Write(buffered); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := buffered.WriteString("\r\n"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := buffered.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
_, _ = io.Copy(upstream, downstream)
|
defer close(done)
|
||||||
done <- struct{}{}
|
if _, err := io.Copy(upstream, io.MultiReader(io.LimitReader(buffered, int64(buffered.Reader.Buffered())), downstream)); err != nil { // Bypass net/http after the prefix so stdin EOF preserves output.
|
||||||
}()
|
_ = upstream.Close()
|
||||||
go func() {
|
} else if writer, ok := upstream.(interface{ CloseWrite() error }); ok {
|
||||||
_, _ = io.Copy(downstream, upstream)
|
_ = writer.CloseWrite()
|
||||||
done <- struct{}{}
|
} else {
|
||||||
|
_ = upstream.Close()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
_, _ = io.Copy(downstream, reader)
|
||||||
|
_ = downstream.Close()
|
||||||
|
_ = upstream.Close()
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeJobResources(ctx context.Context, job string) error {
|
func RemoveDockerJobResources(ctx context.Context, job string) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -246,27 +562,22 @@ func removeLabelled(ctx context.Context, cli client.APIClient, job string) error
|
|||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
filters := make(client.Filters).Add("label", jobLabel+"="+job)
|
filters := make(client.Filters).Add("label", jobLabel+"="+job)
|
||||||
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
||||||
if err != nil {
|
errs := []error{err}
|
||||||
return err
|
|
||||||
}
|
|
||||||
var errs []error
|
|
||||||
for _, c := range containers.Items {
|
for _, c := range containers.Items {
|
||||||
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
|
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
|
||||||
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
|
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
|
||||||
}
|
}
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
||||||
if err != nil {
|
errs = append(errs, err)
|
||||||
return errors.Join(append(errs, err)...)
|
|
||||||
}
|
|
||||||
for _, n := range networks.Items {
|
for _, n := range networks.Items {
|
||||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); n.Scope == "swarm" && cerrdefs.IsInvalidArgument(err) { // swarm refuses while a service or its tasks use it
|
||||||
|
logger.Infof("keeping network %s, a swarm service still uses it", n.Name)
|
||||||
|
} else if err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
|
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
|
||||||
if err != nil {
|
errs = append(errs, err)
|
||||||
return errors.Join(append(errs, err)...)
|
|
||||||
}
|
|
||||||
for _, v := range volumes.Items {
|
for _, v := range volumes.Items {
|
||||||
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
|
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
|
||||||
|
|||||||
@@ -10,17 +10,23 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
|
"github.com/moby/moby/api/types/image"
|
||||||
|
"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/volume"
|
"github.com/moby/moby/api/types/volume"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
@@ -56,7 +62,12 @@ func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerProxy(t *testing.T) {
|
func TestDockerProxy(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Unix socket ownership is unavailable on Windows")
|
||||||
|
}
|
||||||
bodies := make(chan []byte, 8)
|
bodies := make(chan []byte, 8)
|
||||||
|
createStarted, releaseCreate := make(chan struct{}), make(chan struct{})
|
||||||
|
streamsDone := make(chan struct{}, 2)
|
||||||
daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
|
daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
|
||||||
listener, err := net.Listen("unix", daemonSocket)
|
listener, err := net.Listen("unix", daemonSocket)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -64,16 +75,33 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
switch {
|
switch {
|
||||||
case strings.HasSuffix(r.URL.Path, "/create"):
|
case strings.HasSuffix(r.URL.Path, "/create"):
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if r.URL.RawQuery == "wait" {
|
||||||
|
close(createStarted)
|
||||||
|
<-releaseCreate
|
||||||
|
}
|
||||||
bodies <- body
|
bodies <- body
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
case strings.HasSuffix(r.URL.Path, "/start"):
|
case r.URL.Path == "/exec/detached/start" || r.URL.Path == "/exec/error/start":
|
||||||
|
if r.URL.Path == "/exec/error/start" {
|
||||||
|
w.Header().Set("Content-Type", "application/vnd.docker.raw-stream")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("ordinary"))
|
||||||
|
case rawStreamPath.MatchString(r.URL.Path) || r.URL.Path == "/session":
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
conn, buffered, err := w.(http.Hijacker).Hijack()
|
conn, buffered, err := w.(http.Hijacker).Hijack()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n"))
|
if r.Header.Get("Upgrade") != "" {
|
||||||
_, _ = io.Copy(conn, buffered)
|
_, _ = conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nready\n"))
|
||||||
|
} else {
|
||||||
|
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\nready\n"))
|
||||||
|
}
|
||||||
|
input, _ := io.ReadAll(buffered)
|
||||||
|
_, _ = conn.Write(append([]byte("final:"), input...))
|
||||||
|
streamsDone <- struct{}{}
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Api-Version", "1.47")
|
w.Header().Set("Api-Version", "1.47")
|
||||||
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
|
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
|
||||||
@@ -83,34 +111,94 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
t.Cleanup(func() { _ = daemon.Close() })
|
t.Cleanup(func() { _ = daemon.Close() })
|
||||||
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
|
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
|
||||||
|
proxy.SetMounts(map[string]string{"/workspace/o/r": "/volumes/job/_data", "/workspace/o/r/tmp": "", "/var/run/docker.sock": "/tmp/p/docker.sock", "/volumes": "/daemon/volumes"})
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket)
|
return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket)
|
||||||
}}}
|
}}}
|
||||||
|
t.Cleanup(client.CloseIdleConnections)
|
||||||
|
dialProxy := func(t *testing.T) *net.UnixConn {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: proxy.Socket, Net: "unix"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
startStream := func(t *testing.T, path, upgrade string) (*net.UnixConn, *bufio.Reader) {
|
||||||
|
t.Helper()
|
||||||
|
conn := dialProxy(t)
|
||||||
|
_, err := fmt.Fprintf(conn, "POST %s HTTP/1.1\r\nHost: docker\r\n%sContent-Length: 2\r\n\r\n{}input", path, upgrade)
|
||||||
|
require.NoError(t, err)
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
resp, err := http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
})
|
||||||
|
prefix, err := reader.ReadString('\n')
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "ready\n", prefix)
|
||||||
|
return conn, reader
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("labels creates", func(t *testing.T) {
|
t.Run("labels creates", func(t *testing.T) {
|
||||||
for path, body := range map[string]string{
|
for _, testCase := range []struct{ path, body, want string }{
|
||||||
"/v1.47/containers/create": `{"Image":"alpine","Labels":{"own":"1"}}`,
|
{
|
||||||
"/networks/create": `{"Name":"n"}`,
|
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
|
||||||
"/volumes/create": `{"Name":"v","labels":null}`,
|
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"Binds":["/workspace/o/r:/src:ro","/workspace/o/r/../r/sub/:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2"}}`,
|
||||||
|
`{"HostConfig":{"Binds":["/volumes/job/_data:/src:ro","/volumes/job/_data/sub:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2","com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/var/run/docker.sock","Target":"/var/run/docker.sock"},{"type":"bind","source":"/workspace/o/r/sub/","target":"/m"},{"Type":"bind","Source":"/workspace/o/r/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/workspace/o/r/../r","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]}}`,
|
||||||
|
`{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/tmp/p/docker.sock","Target":"/var/run/docker.sock"},{"Type":"bind","Source":"/volumes/job/_data/sub/","target":"/m"},{"Type":"bind","Source":"/volumes/job/_data/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/volumes/job/_data","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/workspace/o/r/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]}}`,
|
||||||
|
`{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/volumes/job/_data/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/containers/create", `{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null}`,
|
||||||
|
`{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null,"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/networks/create", `{"Name":"n"}`,
|
||||||
|
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Name":"v","labels":null,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
||||||
|
`{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"},"DriverOpts":{"type":"none","o":"rbind,ro","device":"/volumes/job/_data/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"}}`,
|
||||||
|
`{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"}}`,
|
||||||
|
`{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", "",
|
||||||
|
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", "null",
|
||||||
|
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/volumes/create", `{"Labels":{"discard":"1"},"labels":null,"LABELS":{"own":"1"},"LABELS":{"extra":"2"}}`,
|
||||||
|
`{"Labels":{"own":"1","extra":"2","com.gitea.runner.job":"job-1"}}`,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
resp, err := client.Post("http://docker"+path, "application/json", strings.NewReader(body))
|
resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
assert.JSONEq(t, testCase.want, string(<-bodies), testCase.body)
|
||||||
var got map[string]json.RawMessage
|
|
||||||
require.NoError(t, json.Unmarshal(<-bodies, &got))
|
|
||||||
key := "Labels"
|
|
||||||
if _, ok := got["labels"]; ok {
|
|
||||||
key = "labels"
|
|
||||||
}
|
|
||||||
var labels map[string]string
|
|
||||||
require.NoError(t, json.Unmarshal(got[key], &labels))
|
|
||||||
assert.Equal(t, "job-1", labels[jobLabel], path)
|
|
||||||
if strings.Contains(body, "own") {
|
|
||||||
assert.Equal(t, "1", labels["own"])
|
|
||||||
assert.JSONEq(t, `"alpine"`, string(got["Image"]))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -123,40 +211,102 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
assert.Equal(t, "OK GET /v1.47/_ping", string(body))
|
assert.Equal(t, "OK GET /v1.47/_ping", string(body))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("tunnels raw streams", func(t *testing.T) {
|
t.Run("tunnels raw streams through stdin EOF", func(t *testing.T) {
|
||||||
conn, err := net.Dial("unix", proxy.Socket)
|
for _, stream := range []struct{ path, upgrade string }{
|
||||||
require.NoError(t, err)
|
{path: "/v1.47/exec/abc/start"},
|
||||||
defer conn.Close()
|
{path: "/containers/abc/attach", upgrade: "Connection: Upgrade\r\nUpgrade: tcp\r\n"},
|
||||||
_, err = conn.Write([]byte("POST /v1.47/exec/abc/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}ping\n"))
|
} {
|
||||||
require.NoError(t, err)
|
conn, reader := startStream(t, stream.path, stream.upgrade)
|
||||||
|
require.NoError(t, conn.CloseWrite())
|
||||||
|
output, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "final:input", string(output))
|
||||||
|
<-streamsDone
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("detached and error responses retain keepalive label injection", func(t *testing.T) {
|
||||||
|
conn := dialProxy(t)
|
||||||
reader := bufio.NewReader(conn)
|
reader := bufio.NewReader(conn)
|
||||||
resp, err := http.ReadResponse(reader, nil)
|
for endpoint, status := range map[string]int{"detached": http.StatusOK, "error": http.StatusBadRequest} {
|
||||||
|
_, err := fmt.Fprintf(conn, "POST /exec/%s/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}", endpoint)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, status, resp.StatusCode)
|
||||||
|
assert.Equal(t, "ordinary", string(body))
|
||||||
|
_, err = io.WriteString(conn, "POST /volumes/create HTTP/1.1\r\nHost: docker\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err = http.ReadResponse(reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("close joins streams and admitted create", func(t *testing.T) {
|
||||||
|
t.Cleanup(func() { close(releaseCreate) })
|
||||||
|
_, raw := startStream(t, "/exec/live/start", "")
|
||||||
|
_, upgraded := startStream(t, "/session", "Connection: Upgrade\r\nUpgrade: tcp\r\n")
|
||||||
|
creator := dialProxy(t)
|
||||||
|
_, err := io.WriteString(creator, "POST /volumes/create?wait HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
<-createStarted
|
||||||
assert.Equal(t, "application/vnd.docker.raw-stream", resp.Header.Get("Content-Type"))
|
require.NoError(t, creator.CloseWrite())
|
||||||
echoed, err := reader.ReadString('\n')
|
closed := make(chan error, 1)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go func() { closed <- proxy.Close(ctx) }()
|
||||||
|
for _, reader := range []*bufio.Reader{raw, upgraded} {
|
||||||
|
_, err := reader.ReadByte()
|
||||||
|
require.ErrorIs(t, err, io.EOF)
|
||||||
|
<-streamsDone
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-closed:
|
||||||
|
t.Fatalf("Close returned before admitted create settled: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
releaseCreate <- struct{}{}
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(creator), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "{}ping\n", echoed)
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
||||||
|
require.NoError(t, <-closed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
||||||
|
containerFailure := errors.New("container removal failed")
|
||||||
|
listFailure := errors.New("listing failed")
|
||||||
|
volumeFailure := errors.New("volume removal failed")
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
|
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
|
||||||
cli := &mockDockerClient{}
|
cli := &mockDockerClient{}
|
||||||
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
||||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil)
|
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
|
||||||
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil)
|
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once()
|
||||||
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once()
|
||||||
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
|
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
|
||||||
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "app_default"}}}, nil)
|
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "stack_default", Scope: "swarm"}}}, listFailure).Once()
|
||||||
cli.On("NetworkRemove", ctx, "n1", mock.Anything).Return(mobyclient.NetworkRemoveResult{}, nil)
|
cli.On("NetworkRemove", ctx, "n1", mobyclient.NetworkRemoveOptions{}).Return(mobyclient.NetworkRemoveResult{}, cerrdefs.ErrInvalidArgument).Once()
|
||||||
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
|
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
|
||||||
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil)
|
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
|
||||||
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, nil)
|
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
|
||||||
|
|
||||||
require.NoError(t, removeLabelled(ctx, cli, "job-1"))
|
err := removeLabelled(ctx, cli, "job-1")
|
||||||
|
require.ErrorIs(t, err, containerFailure)
|
||||||
|
require.ErrorIs(t, err, listFailure)
|
||||||
|
require.ErrorIs(t, err, volumeFailure)
|
||||||
|
require.NotErrorIs(t, err, cerrdefs.ErrInvalidArgument)
|
||||||
|
require.ErrorContains(t, err, "failed to remove container c1")
|
||||||
|
require.ErrorContains(t, err, "failed to remove volume app_data")
|
||||||
cli.AssertExpectations(t)
|
cli.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,13 +318,17 @@ func TestDockerProxyWithDaemon(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer direct.Close()
|
defer direct.Close()
|
||||||
dir := shortTempDir(t)
|
dir := shortTempDir(t)
|
||||||
seen, err := daemonSeesDir(ctx, dir)
|
seen, err := daemonSeesDir(ctx, direct, dir, dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Logf("daemon sees the runner's filesystem: %v", seen)
|
t.Logf("daemon sees the runner's filesystem: %v", seen)
|
||||||
|
seen, err = daemonSeesDir(ctx, direct, dir, shortTempDir(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, seen)
|
||||||
|
|
||||||
job := "proxy-test-" + t.Name()
|
job := "proxy-test-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||||
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
|
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
|
||||||
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
|
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer viaProxy.Close()
|
defer viaProxy.Close()
|
||||||
@@ -212,6 +366,7 @@ func TestDockerProxyWithDaemon(t *testing.T) {
|
|||||||
attached.Close()
|
attached.Close()
|
||||||
|
|
||||||
require.NoError(t, proxy.Close(ctx))
|
require.NoError(t, proxy.Close(ctx))
|
||||||
|
require.NoError(t, RemoveDockerJobResources(ctx, job))
|
||||||
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
assert.True(t, cerrdefs.IsNotFound(err))
|
assert.True(t, cerrdefs.IsNotFound(err))
|
||||||
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
|
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
|
||||||
@@ -268,3 +423,121 @@ func BenchmarkDockerProxy(b *testing.B) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type probeClient struct {
|
||||||
|
mobyclient.APIClient
|
||||||
|
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
||||||
|
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
||||||
|
stat func(string) error
|
||||||
|
mounts []container.MountPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerInspect(context.Context, string, mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||||
|
return mobyclient.ContainerInspectResult{Container: container.InspectResponse{Mounts: c.mounts}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) {
|
||||||
|
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "probe-image"}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
return c.create(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
return c.remove(ctx, id, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *probeClient) ContainerStatPath(_ context.Context, _ string, opts mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
||||||
|
return mobyclient.ContainerStatPathResult{}, c.stat(opts.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSeesDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
markers := make(map[string]bool)
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
daemonDir string
|
||||||
|
removeErr error
|
||||||
|
seen bool
|
||||||
|
removed bool
|
||||||
|
}{
|
||||||
|
{name: "cleanup after cancellation", daemonDir: dir, seen: true, removed: true},
|
||||||
|
{name: "daemon without the directory", daemonDir: filepath.Join(dir, "missing")},
|
||||||
|
{name: "daemon with a different directory at that path", daemonDir: t.TempDir(), removed: true},
|
||||||
|
{name: "cleanup error after cancellation", daemonDir: dir, removeErr: errors.New("cleanup failed"), removed: true},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
removed := false
|
||||||
|
cli := &probeClient{
|
||||||
|
create: func(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)
|
||||||
|
cancel()
|
||||||
|
if _, err := os.Stat(testCase.daemonDir); err != nil {
|
||||||
|
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
|
||||||
|
},
|
||||||
|
stat: func(path string) error {
|
||||||
|
marker := strings.TrimPrefix(path, "/gitea-runner-probe/")
|
||||||
|
assert.False(t, markers[marker])
|
||||||
|
markers[marker] = true
|
||||||
|
if _, err := os.Stat(filepath.Join(testCase.daemonDir, marker)); err != nil {
|
||||||
|
return cerrdefs.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
remove: func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
|
removed = true
|
||||||
|
assert.Equal(t, "probe", id)
|
||||||
|
assert.Equal(t, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, opts)
|
||||||
|
require.NoError(t, ctx.Err())
|
||||||
|
_, bounded := ctx.Deadline()
|
||||||
|
assert.True(t, bounded)
|
||||||
|
return mobyclient.ContainerRemoveResult{}, testCase.removeErr
|
||||||
|
},
|
||||||
|
}
|
||||||
|
seen, err := daemonSeesDir(ctx, cli, dir, testCase.daemonDir)
|
||||||
|
require.ErrorIs(t, err, testCase.removeErr)
|
||||||
|
assert.Equal(t, testCase.seen, seen)
|
||||||
|
assert.Equal(t, testCase.removed, removed)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerContainerWorkdir(t *testing.T) {
|
||||||
|
workdir := t.TempDir()
|
||||||
|
t.Chdir(workdir)
|
||||||
|
volume := container.MountPoint{Type: mount.TypeVolume, Source: "/daemon/_data", Destination: filepath.Dir(workdir)}
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
mount container.MountPoint
|
||||||
|
foreign bool
|
||||||
|
daemonDir string
|
||||||
|
}{
|
||||||
|
{name: "volume above the working directory", mount: volume, daemonDir: filepath.Join(volume.Source, filepath.Base(workdir))},
|
||||||
|
{name: "tmpfs", mount: container.MountPoint{Type: mount.TypeTmpfs, Destination: volume.Destination}},
|
||||||
|
{name: "hostname of another container", mount: volume, foreign: true},
|
||||||
|
} {
|
||||||
|
cli := &probeClient{
|
||||||
|
mounts: []container.MountPoint{testCase.mount},
|
||||||
|
stat: func(path string) error {
|
||||||
|
if _, err := os.Stat(path); err != nil || testCase.foreign {
|
||||||
|
return cerrdefs.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dir, daemonDir := runnerContainerWorkdir(t.Context(), cli)
|
||||||
|
assert.Equal(t, testCase.daemonDir, daemonDir, testCase.name)
|
||||||
|
assert.Equal(t, testCase.daemonDir != "", dir == workdir, testCase.name)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(workdir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyDockerSocketPermissions(daemonSocket, socket string, info os.FileInfo) error {
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("docker socket ownership is unavailable")
|
||||||
|
}
|
||||||
|
groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid))
|
||||||
|
if groupErr != nil && (!errors.Is(groupErr, fs.ErrPermission) || int(stat.Uid) != os.Geteuid()) {
|
||||||
|
return groupErr
|
||||||
|
}
|
||||||
|
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mode := uint16(info.Mode().Perm())
|
||||||
|
if err := os.Chmod(socket, fs.FileMode(mode)); err != nil || groupErr == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if size, err := unix.Getxattr(daemonSocket, "system.posix_acl_access", nil); err == nil && size > 0 {
|
||||||
|
return groupErr
|
||||||
|
}
|
||||||
|
owner, group, other := mode>>6, mode>>3&7, mode&7
|
||||||
|
acl := binary.LittleEndian.AppendUint32(nil, 2)
|
||||||
|
for _, entry := range []struct {
|
||||||
|
tag, perm uint16
|
||||||
|
id uint32
|
||||||
|
}{{1, owner, math.MaxUint32}, {4, other, math.MaxUint32}, {8, group, stat.Gid}, {16, group | other, math.MaxUint32}, {32, other, math.MaxUint32}} {
|
||||||
|
acl = binary.LittleEndian.AppendUint32(binary.LittleEndian.AppendUint16(binary.LittleEndian.AppendUint16(acl, entry.tag), entry.perm), entry.id)
|
||||||
|
}
|
||||||
|
return unix.Setxattr(socket, "system.posix_acl_access", acl, 0) // names the group a rootless runner cannot chown to, its own group keeps what others had
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !WITHOUT_DOCKER
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error {
|
||||||
|
return errors.New("docker socket ownership cannot be preserved on Windows")
|
||||||
|
}
|
||||||
+50
-36
@@ -14,6 +14,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -27,13 +28,13 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
|
||||||
"dario.cat/mergo"
|
"dario.cat/mergo"
|
||||||
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/cli/cli/connhelper"
|
"github.com/docker/cli/cli/connhelper"
|
||||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||||
"github.com/go-git/go-billy/v5/osfs"
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
"github.com/gobwas/glob"
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"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"
|
||||||
@@ -257,6 +258,27 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
|
|||||||
for _, mountPoint := range inspect.Mounts {
|
for _, mountPoint := range inspect.Mounts {
|
||||||
info.Mounts[mountPoint.Destination] = mountPoint.Source
|
info.Mounts[mountPoint.Destination] = mountPoint.Source
|
||||||
}
|
}
|
||||||
|
if hostConfig := inspect.HostConfig; hostConfig != nil { // Mounts omits --tmpfs targets and subpaths
|
||||||
|
for target := range hostConfig.Tmpfs {
|
||||||
|
info.Mounts[path.Clean(target)] = ""
|
||||||
|
}
|
||||||
|
for _, spec := range hostConfig.Mounts {
|
||||||
|
subpath := ""
|
||||||
|
if spec.VolumeOptions != nil {
|
||||||
|
subpath = spec.VolumeOptions.Subpath
|
||||||
|
} else if spec.ImageOptions != nil {
|
||||||
|
subpath = spec.ImageOptions.Subpath
|
||||||
|
}
|
||||||
|
if target := path.Clean(spec.Target); subpath != "" && info.Mounts[target] != "" {
|
||||||
|
info.Mounts[target] = path.Join(info.Mounts[target], subpath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range []string{"/etc/hosts", "/etc/hostname", "/etc/resolv.conf"} { // specific to the job's network namespace
|
||||||
|
if _, mounted := info.Mounts[target]; !mounted {
|
||||||
|
info.Mounts[target] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if state := inspect.State; state != nil {
|
if state := inspect.State; state != nil {
|
||||||
info.State = string(state.Status)
|
info.State = string(state.Status)
|
||||||
@@ -488,14 +510,11 @@ func (cr *containerReference) remove() common.Executor {
|
|||||||
RemoveVolumes: true,
|
RemoveVolumes: true,
|
||||||
Force: true,
|
Force: true,
|
||||||
})
|
})
|
||||||
switch {
|
if cerrdefs.IsConflict(err) {
|
||||||
case cerrdefs.IsConflict(err):
|
err = cr.waitForRemoval(ctx, idOrName)
|
||||||
// the daemon's own AutoRemove teardown is running, and it releases the volume
|
}
|
||||||
// references and the network endpoint only once it finishes
|
if err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
cr.waitForRemoval(ctx, idOrName)
|
return fmt.Errorf("failed to remove container %s: %w", idOrName, err)
|
||||||
case err != nil && !cerrdefs.IsNotFound(err):
|
|
||||||
logger.Error(fmt.Errorf("failed to remove container %s: %w", idOrName, err))
|
|
||||||
return nil // keep the id, the container is still there for a later Remove()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Removed container: %v", idOrName)
|
logger.Debugf("Removed container: %v", idOrName)
|
||||||
@@ -504,7 +523,7 @@ func (cr *containerReference) remove() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
|
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) error {
|
||||||
// per container, against the one minute the post-job executor allows for the whole
|
// per container, against the one minute the post-job executor allows for the whole
|
||||||
// cleanup, so a job with several services can spend most of that budget here
|
// cleanup, so a job with several services can spend most of that budget here
|
||||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
@@ -514,18 +533,13 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
|
|||||||
Condition: container.WaitConditionRemoved,
|
Condition: container.WaitConditionRemoved,
|
||||||
})
|
})
|
||||||
select {
|
select {
|
||||||
case <-waitResult.Result:
|
case result := <-waitResult.Result:
|
||||||
case <-waitResult.Error:
|
if result.Error != nil {
|
||||||
case <-ctx.Done():
|
return errors.New(result.Error.Message)
|
||||||
// the client delivers the result over an unbuffered channel, so leave a receiver
|
}
|
||||||
// behind or its goroutine parks on the send for the lifetime of the process
|
return nil
|
||||||
go func() {
|
case err := <-waitResult.Error:
|
||||||
select {
|
return err
|
||||||
case <-waitResult.Result:
|
|
||||||
case <-waitResult.Error:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1277,15 +1291,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
|
|||||||
|
|
||||||
type validVolumeMatcher struct {
|
type validVolumeMatcher struct {
|
||||||
allowAll bool
|
allowAll bool
|
||||||
named []glob.Glob
|
named []string
|
||||||
host []glob.Glob
|
host []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
ret := validVolumeMatcher{
|
ret := validVolumeMatcher{
|
||||||
named: make([]glob.Glob, 0, len(validVolumes)),
|
named: make([]string, 0, len(validVolumes)),
|
||||||
host: make([]glob.Glob, 0, len(validVolumes)),
|
host: make([]string, 0, len(validVolumes)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range validVolumes {
|
for _, v := range validVolumes {
|
||||||
@@ -1294,10 +1308,10 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !isHostVolumePattern(v) {
|
if !isHostVolumePattern(v) {
|
||||||
if g, err := glob.Compile(v); err != nil {
|
if doublestar.ValidatePattern(v) {
|
||||||
logger.Errorf("create glob from %s error: %v", v, err)
|
ret.named = append(ret.named, v)
|
||||||
} else {
|
} else {
|
||||||
ret.named = append(ret.named, g)
|
logger.Errorf("invalid volume pattern %s", v)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1306,10 +1320,10 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
|
|||||||
logger.Errorf("normalize volume pattern %s error: %v", v, err)
|
logger.Errorf("normalize volume pattern %s error: %v", v, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if g, err := glob.Compile(normalized); err != nil {
|
if doublestar.ValidatePathPattern(normalized) {
|
||||||
logger.Errorf("create glob from %s error: %v", normalized, err)
|
ret.host = append(ret.host, normalized)
|
||||||
} else {
|
} else {
|
||||||
ret.host = append(ret.host, g)
|
logger.Errorf("invalid volume pattern %s", normalized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1325,15 +1339,15 @@ func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, g := range m.host {
|
for _, pattern := range m.host {
|
||||||
if g.Match(normalized) {
|
if doublestar.PathMatchUnvalidated(pattern, normalized) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, g := range m.named {
|
for _, pattern := range m.named {
|
||||||
if g.Match(source) {
|
if doublestar.MatchUnvalidated(pattern, source) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,18 +345,22 @@ func TestDockerWaitFailure(t *testing.T) {
|
|||||||
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||||
// be logged as one.
|
// be logged as one.
|
||||||
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||||
|
removeFailure := errors.New("driver failed to remove root filesystem")
|
||||||
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||||
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
err error
|
err error
|
||||||
wantWait bool
|
wantWait bool
|
||||||
wantFailure bool
|
waitErr error
|
||||||
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
||||||
|
{name: "wait canceled", err: cerrdefs.ErrConflict, wantWait: true, waitErr: context.Canceled, wantErr: context.Canceled},
|
||||||
|
{name: "removed during wait", err: cerrdefs.ErrConflict, wantWait: true, waitErr: cerrdefs.ErrNotFound},
|
||||||
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
||||||
{name: "removed cleanly", err: nil},
|
{name: "removed cleanly", err: nil},
|
||||||
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := test.NewNullLogger()
|
||||||
@@ -366,21 +370,24 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||||
if tc.wantWait {
|
if tc.wantWait {
|
||||||
removed := make(chan container.WaitResponse, 1)
|
removed := make(chan container.WaitResponse, 1)
|
||||||
removed <- container.WaitResponse{}
|
waitErrors := make(chan error, 1)
|
||||||
|
if tc.waitErr == nil {
|
||||||
|
removed <- container.WaitResponse{}
|
||||||
|
} else {
|
||||||
|
waitErrors <- tc.waitErr
|
||||||
|
}
|
||||||
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||||
Return(mobyclient.ContainerWaitResult{Result: removed})
|
Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
|
||||||
}
|
}
|
||||||
cr := &containerReference{id: "abc", cli: client}
|
cr := &containerReference{id: "abc", cli: client}
|
||||||
|
|
||||||
require.NoError(t, cr.remove()(ctx))
|
require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
|
||||||
// a failure keeps the id, so a later Remove() can retry it
|
if tc.wantErr != nil {
|
||||||
if tc.wantFailure {
|
|
||||||
assert.Equal(t, "abc", cr.id)
|
assert.Equal(t, "abc", cr.id)
|
||||||
assert.Len(t, hook.AllEntries(), 1)
|
|
||||||
} else {
|
} else {
|
||||||
assert.Empty(t, cr.id)
|
assert.Empty(t, cr.id)
|
||||||
assert.Empty(t, hook.AllEntries())
|
|
||||||
}
|
}
|
||||||
|
assert.Empty(t, hook.AllEntries())
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -553,11 +560,14 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
"shared_volume",
|
"shared_volume",
|
||||||
"/home/test/data",
|
"/home/test/data",
|
||||||
"/etc/conf.d/*.json",
|
"/etc/conf.d/*.json",
|
||||||
|
"/opt/**",
|
||||||
},
|
},
|
||||||
binds: []string{
|
binds: []string{
|
||||||
"shared_volume:/shared_volume",
|
"shared_volume:/shared_volume",
|
||||||
"/home/test/data:/test_data",
|
"/home/test/data:/test_data",
|
||||||
"/etc/conf.d/base.json:/config/base.json",
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"/etc/conf.d/sub/nested.json:/config/nested.json",
|
||||||
|
"/opt/app/cache:/cache",
|
||||||
"sql_data:/sql_data",
|
"sql_data:/sql_data",
|
||||||
"/secrets/keys:/keys",
|
"/secrets/keys:/keys",
|
||||||
},
|
},
|
||||||
@@ -565,6 +575,7 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
"shared_volume:/shared_volume",
|
"shared_volume:/shared_volume",
|
||||||
"/home/test/data:/test_data",
|
"/home/test/data:/test_data",
|
||||||
"/etc/conf.d/base.json:/config/base.json",
|
"/etc/conf.d/base.json:/config/base.json",
|
||||||
|
"/opt/app/cache:/cache",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -807,12 +818,23 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
|||||||
Mounts: []container.MountPoint{
|
Mounts: []container.MountPoint{
|
||||||
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"},
|
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"},
|
||||||
{Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"},
|
{Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"},
|
||||||
|
{Type: mount.TypeVolume, Name: "cache", Source: "/var/lib/docker/volumes/cache/_data", Destination: "/cache"},
|
||||||
|
{Type: mount.TypeBind, Source: "/custom/resolv.conf", Destination: "/etc/resolv.conf"},
|
||||||
|
},
|
||||||
|
HostConfig: &container.HostConfig{
|
||||||
|
Tmpfs: map[string]string{"/workspace/owner/repo//tmp/": "size=1m"},
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "cache", Target: "/cache/", VolumeOptions: &mount.VolumeOptions{Subpath: "project"}}},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.Equal(t, map[string]string{
|
assert.Equal(t, map[string]string{
|
||||||
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
|
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
|
||||||
"/var/run/docker.sock": "/var/run/docker.sock",
|
"/workspace/owner/repo/tmp": "",
|
||||||
|
"/var/run/docker.sock": "/var/run/docker.sock",
|
||||||
|
"/cache": "/var/lib/docker/volumes/cache/_data/project",
|
||||||
|
"/etc/resolv.conf": "/custom/resolv.conf",
|
||||||
|
"/etc/hosts": "",
|
||||||
|
"/etc/hostname": "",
|
||||||
}, info.Mounts)
|
}, info.Mounts)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package container
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/netip"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,6 +18,10 @@ import (
|
|||||||
"github.com/moby/moby/api/types/system"
|
"github.com/moby/moby/api/types/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func IsolatedCacheContainer(context.Context, netip.Addr, string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
// requested name, tag and architecture exists in the local docker image store
|
// requested name, tag and architecture exists in the local docker image store
|
||||||
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||||
@@ -29,8 +34,12 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
|||||||
return false, errors.New("Unsupported Operation")
|
return false, errors.New("Unsupported Operation")
|
||||||
}
|
}
|
||||||
|
|
||||||
func DockerProxyDir(ctx context.Context) string {
|
func RemoveDockerJobResources(_ context.Context, _ string) error {
|
||||||
return ""
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerProxy(_ context.Context, _ string) *DockerProxy {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||||
@@ -82,6 +91,26 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
func NewDockerNetworkConnectExecutor(_, _ string) common.Executor {
|
||||||
|
return func(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkDisconnectExecutor(_, _ string) common.Executor {
|
||||||
|
return func(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanNetworks(_ context.Context, _, _ string, _ time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -8,40 +8,67 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||||
return func(ctx context.Context) error {
|
cli, err := GetDockerClient(ctx)
|
||||||
cli, err := GetDockerClient(ctx)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
|
|
||||||
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, vol := range list.Items {
|
|
||||||
if vol.Name == volumeName {
|
|
||||||
return removeExecutor(volumeName, force)(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume not found - do nothing
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
for _, volumeName := range volumeNames {
|
||||||
|
if _, err := cli.VolumeCreate(ctx, client.VolumeCreateOptions{
|
||||||
|
Name: volumeName,
|
||||||
|
Labels: runnerLabels(runnerUUID),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeExecutor(volume string, force bool) common.Executor {
|
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
cli, err := GetDockerClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{
|
||||||
|
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID).Add("dangling", "true"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for _, item := range volumes.Items {
|
||||||
|
created, err := time.Parse(time.RFC3339, item.CreatedAt)
|
||||||
|
// an unreadable or recent timestamp may belong to a job that is still starting up
|
||||||
|
if err != nil || created.After(createdBefore) || item.Labels[runnerUUIDLabel] != runnerUUID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := cli.VolumeRemove(ctx, item.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", item.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
common.Logger(ctx).Infof("removed docker volume %s left behind by an earlier job", item.Name)
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
common.Logger(ctx).Debugf("docker volume rm %s", volumeName)
|
||||||
logger.Debugf("docker volume rm %s", volume)
|
|
||||||
|
|
||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -53,7 +80,10 @@ func removeExecutor(volume string, force bool) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
_, err = cli.VolumeRemove(ctx, volumeName, client.VolumeRemoveOptions{Force: force})
|
||||||
|
if cerrdefs.IsNotFound(err) { // already gone is the outcome we wanted
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
@@ -25,6 +26,26 @@ var (
|
|||||||
findGithubRepo = git.FindGithubRepo
|
findGithubRepo = git.FindGithubRepo
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type pinnedKey struct{}
|
||||||
|
|
||||||
|
// WithPinnedCheckout keeps a run's sha and ref fixed when HEAD moves.
|
||||||
|
func WithPinnedCheckout(ctx context.Context) context.Context {
|
||||||
|
if ctx.Value(pinnedKey{}) != nil {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, pinnedKey{}, &sync.Map{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func pinned(ctx context.Context, key string, lookup func() (string, error)) (string, error) {
|
||||||
|
cache, ok := ctx.Value(pinnedKey{}).(*sync.Map)
|
||||||
|
if !ok {
|
||||||
|
return lookup()
|
||||||
|
}
|
||||||
|
cached, _ := cache.LoadOrStore(key, sync.OnceValues(lookup))
|
||||||
|
lookupOnce, _ := cached.(func() (string, error))
|
||||||
|
return lookupOnce()
|
||||||
|
}
|
||||||
|
|
||||||
// SetRef resolves the ref of the context from its event payload, falling back
|
// SetRef resolves the ref of the context from its event payload, falling back
|
||||||
// to the ref checked out in repoPath.
|
// to the ref checked out in repoPath.
|
||||||
func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||||
@@ -51,7 +72,7 @@ func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ghc.Ref == "" {
|
if ghc.Ref == "" {
|
||||||
ref, err := findGitRef(ctx, repoPath)
|
ref, err := pinned(ctx, "ref:"+repoPath, func() (string, error) { return findGitRef(ctx, repoPath) })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("unable to get git ref: %v", err)
|
logger.Warningf("unable to get git ref: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -95,7 +116,10 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ghc.Sha == "" {
|
if ghc.Sha == "" {
|
||||||
_, sha, err := findGitRevision(ctx, repoPath)
|
sha, err := pinned(ctx, "sha:"+repoPath, func() (string, error) {
|
||||||
|
_, revision, err := findGitRevision(ctx, repoPath)
|
||||||
|
return revision, err
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("unable to get git revision: %v", err)
|
logger.Warningf("unable to get git revision: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -214,4 +214,17 @@ func TestSetSha(t *testing.T) {
|
|||||||
assert.Equal(t, table.sha, ghc.Sha)
|
assert.Equal(t, table.sha, ghc.Sha)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("pinned checkout keeps the first sha and ref", func(t *testing.T) {
|
||||||
|
ctx := WithPinnedCheckout(context.Background())
|
||||||
|
for _, head := range []string{"first", "moved"} {
|
||||||
|
findGitRevision = func(context.Context, string) (string, string, error) { return "", head, nil }
|
||||||
|
findGitRef = func(context.Context, string) (string, error) { return "refs/heads/" + head, nil }
|
||||||
|
ghc := &model.GithubContext{Event: map[string]any{}}
|
||||||
|
SetSha(ctx, ghc, "/some/dir")
|
||||||
|
SetRef(ctx, ghc, "/some/dir")
|
||||||
|
assert.Equal(t, "first", ghc.Sha)
|
||||||
|
assert.Equal(t, "refs/heads/first", ghc.Ref)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
+15
-37
@@ -13,6 +13,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -147,7 +148,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
|||||||
|
|
||||||
if !rc.Config.NoActionPatch {
|
if !rc.Config.NoActionPatch {
|
||||||
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
|
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
|
||||||
patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))
|
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||||
@@ -353,12 +354,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
|
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
eval := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
cmd, err := shellquote.Split(step.getStepModel().With["args"])
|
||||||
args, err := eval.Interpolate(ctx, step.getStepModel().With["args"])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate with.args: %w", err)
|
|
||||||
}
|
|
||||||
cmd, err := shellquote.Split(args)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -375,14 +371,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
entrypoint, err := dockerEntrypoint(step, stage)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
prepImage,
|
prepImage,
|
||||||
stepContainer.Pull(forcePull),
|
stepContainer.Pull(forcePull),
|
||||||
@@ -394,7 +387,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
|
|
||||||
// dockerEntrypoint returns the entrypoint the action's image runs with for the given
|
// dockerEntrypoint returns the entrypoint the action's image runs with for the given
|
||||||
// stage. Only the main stage honours the `entrypoint` input.
|
// stage. Only the main stage honours the `entrypoint` input.
|
||||||
func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEvaluator, stage stepStage) ([]string, error) {
|
func dockerEntrypoint(step actionStep, stage stepStage) ([]string, error) {
|
||||||
runs := step.getActionModel().Runs
|
runs := step.getActionModel().Runs
|
||||||
|
|
||||||
var entrypoint string
|
var entrypoint string
|
||||||
@@ -406,11 +399,7 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
|
|||||||
default:
|
default:
|
||||||
entrypoint = runs.Entrypoint
|
entrypoint = runs.Entrypoint
|
||||||
if entrypoint == "" {
|
if entrypoint == "" {
|
||||||
withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])
|
if fields := strings.Fields(step.getStepModel().With["entrypoint"]); len(fields) > 0 {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
|
|
||||||
}
|
|
||||||
if fields := strings.Fields(withEntrypoint); len(fields) > 0 {
|
|
||||||
return fields, nil
|
return fields, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,7 +414,6 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
|
|||||||
// evalDockerEnv returns an evaluator bound to the environment it installed.
|
// evalDockerEnv returns an evaluator bound to the environment it installed.
|
||||||
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
|
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
inputs := make(map[string]string)
|
inputs := make(map[string]string)
|
||||||
@@ -436,27 +424,20 @@ func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expre
|
|||||||
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
|
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for k, v := range stepModel.With {
|
mergeIntoMap(step, step.getEnv(), inputs, step.getStepModel().With)
|
||||||
if inputs[k], err = eval.Interpolate(ctx, v); err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to interpolate with.%s: %w", k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mergeIntoMap(step, step.getEnv(), inputs)
|
|
||||||
|
|
||||||
env := make(map[string]string, len(action.Runs.Env)+len(*step.getEnv()))
|
|
||||||
mergeIntoMap(step, &env, action.Runs.Env, *step.getEnv())
|
|
||||||
*step.getEnv() = env
|
|
||||||
|
|
||||||
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
||||||
for k, v := range *step.getEnv() {
|
runsEnv := make(map[string]string, len(action.Runs.Env))
|
||||||
if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil {
|
for k, v := range action.Runs.Env {
|
||||||
|
if runsEnv[k], err = ee.Interpolate(ctx, v); err != nil {
|
||||||
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
mergeIntoMap(step, step.getEnv(), runsEnv, maps.Clone(*step.getEnv()))
|
||||||
return ee, nil
|
return ee, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) (container.Container, error) {
|
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
@@ -466,10 +447,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
|
|
||||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
|
|
||||||
binds, mounts, err := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts()
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
if rc.IsHostEnv() {
|
if rc.IsHostEnv() {
|
||||||
networkMode = "default"
|
networkMode = "default"
|
||||||
@@ -493,7 +471,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
AutoRemove: true,
|
AutoRemove: true,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.validVolumes(),
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
}), nil
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
|||||||
Parent: parent,
|
Parent: parent,
|
||||||
EventJSON: parent.EventJSON,
|
EventJSON: parent.EventJSON,
|
||||||
platformImage: parent.platformImage,
|
platformImage: parent.platformImage,
|
||||||
|
jobIndex: parent.jobIndex,
|
||||||
|
jobTotal: parent.jobTotal,
|
||||||
}
|
}
|
||||||
compositerc.setCompositeActionEnv(env)
|
compositerc.setCompositeActionEnv(env)
|
||||||
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
||||||
|
|||||||
@@ -12,18 +12,21 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompositeActionParity(t *testing.T) {
|
func TestCompositeActionParity(t *testing.T) {
|
||||||
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
|
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
strategy := &model.Strategy{MaxParallel: 3}
|
strategy := &model.Strategy{MaxParallelString: "3", RawMatrix: yaml.Node{Kind: yaml.MappingNode}}
|
||||||
parent := &RunContext{
|
parent := &RunContext{
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
Matrix: map[string]any{"os": "linux"},
|
Matrix: map[string]any{"os": "linux"},
|
||||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
|
||||||
JobContainer: &jobContainerMock{},
|
JobContainer: &jobContainerMock{},
|
||||||
platformImage: "-self-hosted",
|
platformImage: "-self-hosted",
|
||||||
|
jobIndex: 1,
|
||||||
|
jobTotal: 4,
|
||||||
}
|
}
|
||||||
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
|
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
|
||||||
Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
|
Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
|
||||||
@@ -36,9 +39,9 @@ func TestCompositeActionParity(t *testing.T) {
|
|||||||
assert.Same(t, strategy, composite.Run.Job().Strategy)
|
assert.Same(t, strategy, composite.Run.Job().Strategy)
|
||||||
assert.True(t, composite.IsHostEnv())
|
assert.True(t, composite.IsHostEnv())
|
||||||
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
|
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
|
||||||
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}")
|
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ strategy.job-index }}|${{ inputs.shared }}")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "linux|3|outer", interpolated)
|
assert.Equal(t, "linux|3|1|outer", interpolated)
|
||||||
assert.NotContains(t, composite.Env, "INPUT_SHARED")
|
assert.NotContains(t, composite.Env, "INPUT_SHARED")
|
||||||
|
|
||||||
nestedEnv := composite.GetEnv()
|
nestedEnv := composite.GetEnv()
|
||||||
@@ -117,3 +120,51 @@ func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, []string{"secret"}, parentMasks)
|
assert.Equal(t, []string{"secret"}, parentMasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompositeStepIfReadsJobStatusExceptInItsOwnMainSteps(t *testing.T) {
|
||||||
|
failStep := func(rc *RunContext) {
|
||||||
|
rc.StepResults["failed"] = &model.StepResult{Conclusion: model.StepStatusFailure}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(job, composite *RunContext)
|
||||||
|
nested bool
|
||||||
|
stage stepStage
|
||||||
|
want map[string]bool
|
||||||
|
}{
|
||||||
|
{"post-if reads the failed job", func(job, _ *RunContext) { failStep(job) }, false, stepStagePost, map[string]bool{"success()": false, "failure()": true, "job.status == 'failure'": true}},
|
||||||
|
{"main if ignores the failed job", func(job, _ *RunContext) { failStep(job) }, false, stepStageMain, map[string]bool{"success()": true, "failure()": false}},
|
||||||
|
{"main if reads the composite's own failure", func(_, composite *RunContext) { failStep(composite) }, false, stepStageMain, map[string]bool{"success()": false, "failure()": true}},
|
||||||
|
{"nested main if ignores the outer composite's failure", func(_, composite *RunContext) { failStep(composite) }, true, stepStageMain, map[string]bool{"success()": true}},
|
||||||
|
{"main if sees the cancelled job", func(job, _ *RunContext) { job.markCancelled() }, false, stepStageMain, map[string]bool{"cancelled()": true, "success()": false}},
|
||||||
|
{"post-if sees the cancelled job", func(job, _ *RunContext) { job.markCancelled() }, false, stepStagePost, map[string]bool{"cancelled()": true, "success()": false}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
job := makeTestRC(t, "runs-on: ubuntu-latest")
|
||||||
|
composite := newTestCompositeRunContext(t, job)
|
||||||
|
rc := composite
|
||||||
|
if tt.nested {
|
||||||
|
rc = newTestCompositeRunContext(t, composite)
|
||||||
|
}
|
||||||
|
tt.setup(job, composite)
|
||||||
|
step := &stepRun{RunContext: rc}
|
||||||
|
for expr, want := range tt.want {
|
||||||
|
enabled, err := isStepEnabled(t.Context(), expr, step, tt.stage)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, enabled, expr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestCompositeRunContext(t *testing.T, parent *RunContext) *RunContext {
|
||||||
|
composite, err := newCompositeRunContext(t.Context(), parent, &stepActionRemote{
|
||||||
|
Step: &model.Step{},
|
||||||
|
RunContext: parent,
|
||||||
|
action: &model.Action{},
|
||||||
|
env: map[string]string{},
|
||||||
|
}, "/action")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return composite
|
||||||
|
}
|
||||||
@@ -242,7 +242,7 @@ func TestActionRunner(t *testing.T) {
|
|||||||
|
|
||||||
tt.step.getRunContext().JobContainer = cm
|
tt.step.getRunContext().JobContainer = cm
|
||||||
|
|
||||||
err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx)
|
err := runActionImpl(tt.step, "dir", mustNewRemoteAction(t, "org/repo/path@ref"))(ctx)
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
@@ -316,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
|||||||
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
||||||
step.On("getEnv").Return(&env)
|
step.On("getEnv").Return(&env)
|
||||||
|
|
||||||
_, _ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
|
newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
|
||||||
|
|
||||||
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
||||||
assert.Empty(t, captured.Username)
|
assert.Empty(t, captured.Username)
|
||||||
@@ -461,18 +461,11 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
|||||||
case <-innerEntered:
|
case <-innerEntered:
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
t.Fatalf("execAsDocker returned without entering inner: %v", err)
|
t.Fatalf("execAsDocker returned without entering inner: %v", err)
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("inner build executor not entered after lock released")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
releaseOnce()
|
releaseOnce()
|
||||||
|
<-done
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerActionImageTag(t *testing.T) {
|
func TestDockerActionImageTag(t *testing.T) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -249,7 +250,8 @@ func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
|
|||||||
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
|
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
|
||||||
// from cancellation (so the steps run) yet still carry a usable error container.
|
// from cancellation (so the steps run) yet still carry a usable error container.
|
||||||
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
||||||
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
jobSpan := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID{1}, SpanID: trace.SpanID{1}})
|
||||||
|
cancelled, cancel := context.WithCancel(trace.ContextWithSpanContext(common.WithJobErrorContainer(context.Background()), jobSpan))
|
||||||
cancel()
|
cancel()
|
||||||
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
||||||
|
|
||||||
@@ -258,6 +260,7 @@ func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
|||||||
|
|
||||||
// Detached from cancellation, so the post steps actually run.
|
// Detached from cancellation, so the post steps actually run.
|
||||||
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
|
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
|
||||||
|
assert.Equal(t, jobSpan, trace.SpanContextFromContext(postCtx), "post steps must stay in the job's trace")
|
||||||
|
|
||||||
// A failing post step records its error instead of panicking.
|
// A failing post step records its error instead of panicking.
|
||||||
require.NotPanics(t, func() {
|
require.NotPanics(t, func() {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
|
"github.com/moby/moby/client"
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerProxyMountedJob(t *testing.T) {
|
||||||
|
mode := os.Getenv("ACT_TEST_DOCKER_PROXY")
|
||||||
|
if mode == "" {
|
||||||
|
t.Skip("set ACT_TEST_DOCKER_PROXY=proxy or direct to verify mounted Docker access")
|
||||||
|
}
|
||||||
|
require.Contains(t, []string{"proxy", "direct"}, mode)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
dockerClient, err := container.GetDockerClient(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { assert.NoError(t, dockerClient.Close()) })
|
||||||
|
require.True(t, strings.HasPrefix(dockerClient.DaemonHost(), "unix://"), "mounted Docker coverage requires a Unix daemon socket")
|
||||||
|
|
||||||
|
fixtureDir, err := filepath.Abs("testdata/docker-proxy")
|
||||||
|
require.NoError(t, err)
|
||||||
|
resourceName := "gitea-proxy-test-" + strings.ToLower(rand.Text())
|
||||||
|
runner, err := New(&Config{
|
||||||
|
Workdir: fixtureDir,
|
||||||
|
ActionCacheDir: t.TempDir(),
|
||||||
|
EventName: "push",
|
||||||
|
PlatformPicker: mapPlatformPicker(platforms),
|
||||||
|
ContainerNamePrefix: resourceName,
|
||||||
|
ContainerDaemonSocket: dockerClient.DaemonHost(),
|
||||||
|
ContainerMaxLifetime: 2 * time.Minute,
|
||||||
|
ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro",
|
||||||
|
ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"},
|
||||||
|
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
plan, err := planner.PlanEvent("push")
|
||||||
|
if mode == "direct" {
|
||||||
|
plan, err = planner.PlanJob("proxy")
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
jobName := runContext.jobContainerName()
|
||||||
|
jobNetwork, _ := runContext.networkNameForGitea()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cleanCtx, cleanCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||||
|
defer cleanCancel()
|
||||||
|
if _, err := dockerClient.ContainerRemove(cleanCtx, jobName, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); !cerrdefs.IsNotFound(err) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{resourceName, jobNetwork} {
|
||||||
|
if _, err := dockerClient.NetworkRemove(cleanCtx, name, client.NetworkRemoveOptions{}); !cerrdefs.IsNotFound(err) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{resourceName, jobName, jobName + "-env"} {
|
||||||
|
if _, err := dockerClient.VolumeRemove(cleanCtx, name, client.VolumeRemoveOptions{}); !cerrdefs.IsNotFound(err) {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hook := &test.Hook{}
|
||||||
|
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHook(ctx, hook)))
|
||||||
|
var messages []string
|
||||||
|
for _, entry := range hook.AllEntries() {
|
||||||
|
messages = append(messages, strings.TrimSpace(entry.Message))
|
||||||
|
}
|
||||||
|
require.Contains(t, messages, "docker proxy post verified")
|
||||||
|
if mode == "proxy" {
|
||||||
|
require.Contains(t, messages, "docker binds verified")
|
||||||
|
}
|
||||||
|
_, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{})
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err)
|
||||||
|
_, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{})
|
||||||
|
if mode == "proxy" {
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err), "labelled network survived cleanup: %v", err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
_, err = dockerClient.VolumeInspect(ctx, resourceName, client.VolumeInspectOptions{})
|
||||||
|
if mode == "proxy" {
|
||||||
|
assert.True(t, cerrdefs.IsNotFound(err), "labelled volume survived cleanup: %v", err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-97
@@ -13,7 +13,6 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -36,24 +35,9 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
var workflowCallResult map[string]*model.WorkflowCallResult
|
var workflowCallResult map[string]*model.WorkflowCallResult
|
||||||
|
|
||||||
// todo: cleanup EvaluationEnvironment creation
|
// todo: cleanup EvaluationEnvironment creation
|
||||||
using := make(map[string]exprparser.Needs)
|
|
||||||
strategy := make(map[string]any)
|
strategy := make(map[string]any)
|
||||||
if rc.Run != nil {
|
if rc.Run != nil {
|
||||||
job := rc.Run.Job()
|
strategy = exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal)
|
||||||
if job != nil && job.Strategy != nil {
|
|
||||||
strategy["fail-fast"] = job.Strategy.FailFast
|
|
||||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs := rc.Run.Workflow.Jobs
|
|
||||||
jobNeeds := rc.Run.Job().Needs()
|
|
||||||
|
|
||||||
for _, needs := range jobNeeds {
|
|
||||||
using[needs] = exprparser.Needs{
|
|
||||||
Outputs: jobs[needs].Outputs,
|
|
||||||
Result: jobs[needs].NeedsResult(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// only setup jobs context in case of workflow_call
|
// only setup jobs context in case of workflow_call
|
||||||
// and existing expression evaluator (this means, jobs are at
|
// and existing expression evaluator (this means, jobs are at
|
||||||
@@ -61,7 +45,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
if rc.caller != nil && rc.ExprEval != nil {
|
if rc.caller != nil && rc.ExprEval != nil {
|
||||||
workflowCallResult = map[string]*model.WorkflowCallResult{}
|
workflowCallResult = map[string]*model.WorkflowCallResult{}
|
||||||
|
|
||||||
for jobName, job := range jobs {
|
for jobName, job := range rc.Run.Workflow.Jobs {
|
||||||
result := model.WorkflowCallResult{
|
result := model.WorkflowCallResult{
|
||||||
Outputs: map[string]string{},
|
Outputs: map[string]string{},
|
||||||
}
|
}
|
||||||
@@ -86,7 +70,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
Vars: getWorkflowVars(ctx, rc),
|
Vars: getWorkflowVars(ctx, rc),
|
||||||
Strategy: strategy,
|
Strategy: strategy,
|
||||||
Matrix: rc.Matrix,
|
Matrix: rc.Matrix,
|
||||||
Needs: using,
|
Needs: exprparser.NeedsContext(rc.Run),
|
||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
HashFiles: getHashFilesFunction(ctx, rc),
|
HashFiles: getHashFilesFunction(ctx, rc),
|
||||||
}
|
}
|
||||||
@@ -105,44 +89,26 @@ var hashfiles string
|
|||||||
|
|
||||||
// NewStepExpressionEvaluator creates a new evaluator with the `inputs` of the enclosing workflow or composite action
|
// NewStepExpressionEvaluator creates a new evaluator with the `inputs` of the enclosing workflow or composite action
|
||||||
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
||||||
return rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs)
|
return rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, rc.getJobContext())
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewActionInputsExpressionEvaluator creates a new evaluator with the step's own with: values as `inputs`
|
// NewActionInputsExpressionEvaluator creates a new evaluator with the step's own with: values as `inputs`
|
||||||
func (rc *RunContext) NewActionInputsExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
func (rc *RunContext) NewActionInputsExpressionEvaluator(ctx context.Context, step step) *ExpressionEvaluator {
|
||||||
return rc.newStepExpressionEvaluator(ctx, step, inputsFromEnv(*step.getEnv()))
|
return rc.newStepExpressionEvaluator(ctx, step, inputsFromEnv(*step.getEnv()), rc.getJobContext())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any) *ExpressionEvaluator {
|
func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step, stepInputs map[string]any, jobContext *model.JobContext) *ExpressionEvaluator {
|
||||||
// todo: cleanup EvaluationEnvironment creation
|
// todo: cleanup EvaluationEnvironment creation
|
||||||
job := rc.Run.Job()
|
|
||||||
strategy := make(map[string]any)
|
|
||||||
if job.Strategy != nil {
|
|
||||||
strategy["fail-fast"] = job.Strategy.FailFast
|
|
||||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs := rc.Run.Workflow.Jobs
|
|
||||||
jobNeeds := rc.Run.Job().Needs()
|
|
||||||
|
|
||||||
using := make(map[string]exprparser.Needs)
|
|
||||||
for _, needs := range jobNeeds {
|
|
||||||
using[needs] = exprparser.Needs{
|
|
||||||
Outputs: jobs[needs].Outputs,
|
|
||||||
Result: jobs[needs].NeedsResult(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ee := &exprparser.EvaluationEnvironment{
|
ee := &exprparser.EvaluationEnvironment{
|
||||||
Github: step.getGithubContext(ctx),
|
Github: step.getGithubContext(ctx),
|
||||||
Env: *step.getEnv(),
|
Env: *step.getEnv(),
|
||||||
Job: rc.getJobContext(),
|
Job: jobContext,
|
||||||
Steps: rc.getStepsContext(),
|
Steps: rc.getStepsContext(),
|
||||||
Secrets: getWorkflowSecrets(rc),
|
Secrets: getWorkflowSecrets(rc),
|
||||||
Vars: getWorkflowVars(ctx, rc),
|
Vars: getWorkflowVars(ctx, rc),
|
||||||
Strategy: strategy,
|
Strategy: exprparser.StrategyContext(rc.Run.Job().Strategy, rc.jobIndex, rc.jobTotal),
|
||||||
Matrix: rc.Matrix,
|
Matrix: rc.Matrix,
|
||||||
Needs: using,
|
Needs: exprparser.NeedsContext(rc.Run),
|
||||||
// todo: should be unavailable
|
// todo: should be unavailable
|
||||||
// but required to interpolate/evaluate the inputs in actions/composite
|
// but required to interpolate/evaluate the inputs in actions/composite
|
||||||
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
|
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
|
||||||
@@ -285,45 +251,30 @@ func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.Gi
|
|||||||
maps.Copy(inputs, rc.workflowCallInputs)
|
maps.Copy(inputs, rc.workflowCallInputs)
|
||||||
maps.Copy(inputs, stepInputs)
|
maps.Copy(inputs, stepInputs)
|
||||||
|
|
||||||
if ghc.EventName == "workflow_dispatch" {
|
switch ghc.EventName {
|
||||||
config := rc.Run.Workflow.WorkflowDispatchConfig()
|
case "workflow_dispatch":
|
||||||
if config != nil && config.Inputs != nil {
|
if config := rc.Run.Workflow.WorkflowDispatchConfig(); config != nil {
|
||||||
for k, v := range config.Inputs {
|
for name, input := range config.Inputs {
|
||||||
value := nestedMapLookup(ghc.Event, "inputs", k)
|
if input.Type == "number" {
|
||||||
if value == nil {
|
input.Type = "string" // boolean is the only typed dispatch input, as in Gitea
|
||||||
value = v.Default
|
|
||||||
}
|
}
|
||||||
inputs[k] = coerceInputValue(value, v.Type)
|
setEvaluatorInput(inputs, ghc, name, model.WorkflowCallInput{Type: input.Type, Default: input.Default})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
case "workflow_call":
|
||||||
|
for name, input := range rc.Run.Workflow.WorkflowCallConfig().Inputs {
|
||||||
if ghc.EventName == "workflow_call" {
|
setEvaluatorInput(inputs, ghc, name, input)
|
||||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
|
||||||
if config != nil && config.Inputs != nil {
|
|
||||||
for k, v := range config.Inputs {
|
|
||||||
value := nestedMapLookup(ghc.Event, "inputs", k)
|
|
||||||
if value == nil {
|
|
||||||
value = v.Default
|
|
||||||
}
|
|
||||||
inputs[k] = coerceInputValue(value, v.Type)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return inputs
|
return inputs
|
||||||
}
|
}
|
||||||
|
|
||||||
// coerceInputValue converts an input value to the type declared by the workflow.
|
func setEvaluatorInput(inputs map[string]any, ghc *model.GithubContext, name string, input model.WorkflowCallInput) {
|
||||||
// The event payload carries natively typed JSON values on newer Gitea versions,
|
value := model.NestedMapLookup(ghc.Event, "inputs", name)
|
||||||
// while defaults and older servers provide strings.
|
if value == nil {
|
||||||
func coerceInputValue(value any, inputType string) any {
|
value, _ = input.DefaultValue()
|
||||||
if inputType != "boolean" {
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
if b, ok := value.(bool); ok {
|
inputs[name] = model.CoerceInputValue(value, input.Type)
|
||||||
return b
|
|
||||||
}
|
|
||||||
return value == "true"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
|
// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
|
||||||
@@ -331,36 +282,32 @@ func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
|
|||||||
if rc.caller == nil {
|
if rc.caller == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
logger := common.Logger(ctx)
|
||||||
callerJob := rc.caller.runContext.Run.Job()
|
callerJob := rc.caller.runContext.Run.Job()
|
||||||
callerEval := rc.caller.runContext.ExprEval
|
callerEval := rc.caller.runContext.ExprEval.shared(ctx)
|
||||||
calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
|
var callerWith map[string]any
|
||||||
|
if err := model.DecodeEvaluated("workflow inputs", callerJob.RawWith, callerEval.EvaluateYamlNode, &callerWith); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !rc.Run.Workflow.IsWorkflowCall() {
|
||||||
|
logger.Warn("workflow_call key is not defined in the referenced workflow")
|
||||||
|
}
|
||||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||||
|
var err error
|
||||||
rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
|
if rc.workflowCallInputs, err = callerEval.ResolveWorkflowCallInputs(config, callerWith); err != nil {
|
||||||
for name, input := range config.Inputs {
|
logger.Warn(err)
|
||||||
value, eval, label := callerJob.With[name], callerEval, "input"
|
|
||||||
if value == nil {
|
|
||||||
value, eval, label = input.Default, calleeEval(), "the default of input"
|
|
||||||
}
|
|
||||||
if str, ok := value.(string); ok {
|
|
||||||
var err error
|
|
||||||
if value, err = eval.Interpolate(ctx, str); err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate %s %s: %w", label, name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rc.workflowCallInputs[name] = coerceInputValue(value, input.Type)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callerJob.InheritSecrets() {
|
||||||
|
rc.workflowCallSecrets = getWorkflowSecrets(rc.caller.runContext)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
secrets := callerJob.Secrets()
|
secrets := callerJob.Secrets()
|
||||||
if secrets == nil && callerJob.InheritSecrets() {
|
if err := config.ValidateSecrets(secrets); err != nil {
|
||||||
secrets = rc.caller.runContext.Config.Secrets
|
logger.Warn(err)
|
||||||
}
|
}
|
||||||
rc.workflowCallSecrets = make(map[string]string, len(secrets))
|
if rc.workflowCallSecrets, err = callerEval.EvaluateCallerSecrets(secrets); err != nil {
|
||||||
for k, v := range secrets {
|
return fmt.Errorf("unable to interpolate %w", err)
|
||||||
var err error
|
|
||||||
if rc.workflowCallSecrets[k], err = callerEval.Interpolate(ctx, v); err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate secret %s: %w", k, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func TestEvaluateRunContext(t *testing.T) {
|
|||||||
out any
|
out any
|
||||||
errMesg string
|
errMesg string
|
||||||
}{
|
}{
|
||||||
{" 1 ", 1, ""},
|
{" 1 ", 1.0, ""},
|
||||||
// {"1 + 3", "4", ""},
|
// {"1 + 3", "4", ""},
|
||||||
// {"(1 + 3) * -2", "-8", ""},
|
// {"(1 + 3) * -2", "-8", ""},
|
||||||
{"'my text'", "my text", ""},
|
{"'my text'", "my text", ""},
|
||||||
@@ -266,7 +266,6 @@ func TestInterpolate(t *testing.T) {
|
|||||||
{"${{ null }}", ""},
|
{"${{ null }}", ""},
|
||||||
{"${{ fromJSON('[1,2]') }}", "Array"},
|
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||||
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||||
{"${{ 1", "${{ 1"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
@@ -277,7 +276,7 @@ func TestInterpolate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now"} {
|
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} {
|
||||||
_, err := ee.Interpolate(context.Background(), in)
|
_, err := ee.Interpolate(context.Background(), in)
|
||||||
assert.Error(t, err, in)
|
assert.Error(t, err, in)
|
||||||
}
|
}
|
||||||
@@ -295,6 +294,8 @@ on:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
default: gitea
|
default: gitea
|
||||||
|
unset:
|
||||||
|
type: boolean
|
||||||
`,
|
`,
|
||||||
"workflow_dispatch": `
|
"workflow_dispatch": `
|
||||||
on:
|
on:
|
||||||
@@ -306,6 +307,8 @@ on:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
default: gitea
|
default: gitea
|
||||||
|
unset:
|
||||||
|
type: boolean
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,9 +360,76 @@ on:
|
|||||||
inputs := getEvaluatorInputs(rc, nil, ghc)
|
inputs := getEvaluatorInputs(rc, nil, ghc)
|
||||||
assert.Equal(t, table.flag, inputs["flag"])
|
assert.Equal(t, table.flag, inputs["flag"])
|
||||||
assert.Equal(t, "gitea", inputs["name"])
|
assert.Equal(t, "gitea", inputs["name"])
|
||||||
|
assert.Equal(t, false, inputs["unset"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("deferred workflow call inputs", func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(workflows["workflow_call"] + `
|
||||||
|
jobs:
|
||||||
|
test: {}
|
||||||
|
call:
|
||||||
|
uses: ./reuse.yml
|
||||||
|
with: ${{ fromJSON(matrix.args) }}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
workflow.GetJob("test")
|
||||||
|
job := workflow.GetJob("call")
|
||||||
|
rawWith := model.CloneYamlNode(job.RawWith)
|
||||||
|
for _, value := range []string{"true", "false"} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
parent, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"${{ 'runner' }}"}`})
|
||||||
|
require.NoError(t, err)
|
||||||
|
child, err := (&runnerImpl{config: &Config{}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]any{"flag": value == "true", "name": "${{ 'runner' }}", "unset": false}, child.workflowCallInputs)
|
||||||
|
assert.Equal(t, rawWith, job.RawWith)
|
||||||
|
assert.Nil(t, job.With)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) {
|
||||||
|
callerWorkflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
jobs:
|
||||||
|
call:
|
||||||
|
uses: ./reuse.yml
|
||||||
|
with: {flag: not-a-boolean}
|
||||||
|
secrets: {undeclared: "${{ secrets.token }}"}
|
||||||
|
inherit:
|
||||||
|
uses: ./reuse.yml
|
||||||
|
secrets: inherit
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
callee, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
flag: {type: boolean, default: true}
|
||||||
|
jobs:
|
||||||
|
test: {}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
notCallable, err := model.ReadWorkflow(strings.NewReader("on: push\njobs:\n test: {}\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
config := &Config{Secrets: map[string]string{"token": "${{ github.token }}"}}
|
||||||
|
resolve := func(callerJobID string, workflow *model.Workflow) *RunContext {
|
||||||
|
parent, err := (&runnerImpl{config: config}).newRunContext(t.Context(), &model.Run{Workflow: callerWorkflow, JobID: callerJobID}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
child, err := (&runnerImpl{config: config, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
called := resolve("call", callee)
|
||||||
|
assert.Equal(t, map[string]any{"flag": true}, called.workflowCallInputs)
|
||||||
|
assert.Equal(t, map[string]string{"undeclared": "${{ github.token }}"}, called.workflowCallSecrets)
|
||||||
|
inherited := resolve("inherit", notCallable)
|
||||||
|
assert.Empty(t, inherited.workflowCallInputs)
|
||||||
|
assert.Equal(t, config.Secrets, inherited.workflowCallSecrets)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJobNameMasksSecrets(t *testing.T) {
|
func TestJobNameMasksSecrets(t *testing.T) {
|
||||||
|
|||||||
+35
-34
@@ -18,7 +18,6 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -263,26 +262,27 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
|
return common.Executor(func(ctx context.Context) error {
|
||||||
|
if err := info.startContainer()(ctx); err != nil {
|
||||||
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
|
return err
|
||||||
Finally(func(ctx context.Context) error {
|
}
|
||||||
|
return newStepsExecutor(rc, preSteps, steps).Finally(func(ctx context.Context) error {
|
||||||
// Record an interrupt (backstop for interrupts that land outside the main
|
// Record an interrupt (backstop for interrupts that land outside the main
|
||||||
// step loop) so the post steps observe the cancelled/failed job status.
|
// step loop) so the post steps observe the cancelled/failed job status.
|
||||||
rc.markInterrupted(ctx.Err())
|
rc.markInterrupted(ctx.Err())
|
||||||
postCtx, cancel := postStepsContext(ctx)
|
postCtx, cancel := postStepsContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return postExecutor(postCtx)
|
return postExecutor(postCtx)
|
||||||
}).
|
})(ctx)
|
||||||
Finally(info.closeContainer()))
|
}).Finally(info.closeContainer())
|
||||||
}
|
}
|
||||||
|
|
||||||
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
||||||
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
|
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
|
||||||
// returned context always carries a fresh bounded deadline and is never itself cancelled.
|
// returned context always carries a fresh bounded deadline and is never itself cancelled.
|
||||||
//
|
//
|
||||||
// - context.Canceled (server cancel): detach from the cancelled context via a fresh root so
|
// - context.Canceled (server cancel): detach with WithoutCancel under a fresh job-error
|
||||||
// the post steps can run.
|
// container, so a failing post step cannot turn the cancellation into a failure.
|
||||||
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
|
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
|
||||||
// keeps the original values — including the job-error container — so the timeout failure and
|
// keeps the original values — including the job-error container — so the timeout failure and
|
||||||
// any post-step error are preserved and the job is still reported as failed.
|
// any post-step error are preserved and the job is still reported as failed.
|
||||||
@@ -290,10 +290,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
switch ctx.Err() {
|
switch ctx.Err() {
|
||||||
case context.Canceled:
|
case context.Canceled:
|
||||||
// The cancelled context is abandoned for a fresh root, which drops the job-error
|
return context.WithTimeout(common.WithJobErrorContainer(context.WithoutCancel(ctx)), 5*time.Minute)
|
||||||
// container installed at the job root. Re-attach a fresh one so a failing post step
|
|
||||||
// records its error via SetJobError instead of panicking on a nil container.
|
|
||||||
return context.WithTimeout(common.WithJobErrorContainer(common.WithLogger(context.Background(), common.Logger(ctx))), 5*time.Minute)
|
|
||||||
case context.DeadlineExceeded:
|
case context.DeadlineExceeded:
|
||||||
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||||
default:
|
default:
|
||||||
@@ -413,41 +410,45 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
|
// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup.
|
||||||
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
env := rc.GetEnv()
|
||||||
|
var workflowEnv map[string]string
|
||||||
|
if rc.Run.Workflow.Env == nil {
|
||||||
|
if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if workflowEnv != nil {
|
||||||
|
rc.Env = mergeMaps(workflowEnv, env)
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
}
|
||||||
var err error
|
var err error
|
||||||
for k, v := range rc.GetEnv() {
|
for k, v := range env {
|
||||||
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||||
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defaults := rc.Run.Job().Defaults.Run
|
var defaults model.Defaults
|
||||||
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil {
|
if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil {
|
||||||
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil {
|
rc.jobRunDefaults = defaults.Run
|
||||||
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err)
|
if rc.containerSpec.Image == "" {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
_, containerEnv := splitContainerEnv(rc.Run.Job().RawContainer)
|
||||||
|
return model.DecodeEvaluated("container env", containerEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &rc.containerSpec.Env)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setJobOutputs(ctx context.Context, rc *RunContext) error {
|
func setJobOutputs(ctx context.Context, rc *RunContext) error {
|
||||||
if rc.caller == nil {
|
if rc.caller == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs
|
callerOutputs, err := rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateWorkflowCallOutputs(rc.Run.Workflow.WorkflowCallConfig())
|
||||||
callerOutputs := make(map[string]string, len(outputs))
|
if err != nil {
|
||||||
ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
|
return fmt.Errorf("unable to interpolate workflow %w", err)
|
||||||
for k, v := range outputs {
|
|
||||||
value := v.Value
|
|
||||||
for range 2 { // two passes, the value resolves through a job output
|
|
||||||
var err error
|
|
||||||
if value, err = ee().Interpolate(ctx, value); err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate workflow output %s: %w", k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
callerOutputs[k] = value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
||||||
@@ -465,7 +466,7 @@ func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (conte
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
|
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
|
||||||
} else if timeout != "" {
|
} else if timeout != "" {
|
||||||
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeoutMinutes > 0 {
|
||||||
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,6 +300,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
result string
|
result string
|
||||||
hasError bool
|
hasError bool
|
||||||
output string
|
output string
|
||||||
|
startError error
|
||||||
|
cancelOnStart bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "zeroSteps",
|
name: "zeroSteps",
|
||||||
@@ -435,6 +437,21 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
result: "failure",
|
result: "failure",
|
||||||
output: "${{ 'test' != test }}",
|
output: "${{ 'test' != test }}",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "start failure",
|
||||||
|
steps: []*model.Step{{ID: "1"}},
|
||||||
|
executedSteps: []string{"startContainer", "closeContainer"},
|
||||||
|
startError: errors.New("start failed"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancelled at startup boundary",
|
||||||
|
steps: []*model.Step{{ID: "1"}},
|
||||||
|
preSteps: []bool{false},
|
||||||
|
postSteps: []bool{true},
|
||||||
|
executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"},
|
||||||
|
result: "cancelled",
|
||||||
|
cancelOnStart: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
contains := func(needle string, haystack []string) bool {
|
contains := func(needle string, haystack []string) bool {
|
||||||
@@ -445,7 +462,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
|
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
|
||||||
ctx := common.WithJobErrorContainer(context.Background())
|
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||||
|
defer cancel()
|
||||||
jim := &jobInfoMock{}
|
jim := &jobInfoMock{}
|
||||||
sfm := &stepFactoryMock{}
|
sfm := &stepFactoryMock{}
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
@@ -461,8 +479,7 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
}
|
}
|
||||||
if tt.output != "" {
|
if tt.output != "" {
|
||||||
rc.Run.Job().Outputs = map[string]string{"bad": tt.output}
|
require.NoError(t, rc.Run.Job().RawOutputs.Encode(map[string]string{"bad": tt.output}))
|
||||||
rc.outputTemplate = map[string]string{"bad": tt.output}
|
|
||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
executorOrder := make([]string, 0)
|
executorOrder := make([]string, 0)
|
||||||
@@ -470,9 +487,12 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
jim.On("steps").Return(tt.steps)
|
jim.On("steps").Return(tt.steps)
|
||||||
|
|
||||||
if len(tt.steps) > 0 {
|
if len(tt.steps) > 0 {
|
||||||
jim.On("startContainer").Return(func(ctx context.Context) error {
|
jim.On("startContainer").Return(func(_ context.Context) error {
|
||||||
executorOrder = append(executorOrder, "startContainer")
|
executorOrder = append(executorOrder, "startContainer")
|
||||||
return nil
|
if tt.cancelOnStart {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
return tt.startError
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +526,7 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
defer sm.AssertExpectations(t)
|
defer sm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tt.steps) > 0 {
|
if len(tt.steps) > 0 && tt.startError == nil {
|
||||||
jim.On("matrix").Return(map[string]any{})
|
jim.On("matrix").Return(map[string]any{})
|
||||||
|
|
||||||
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
||||||
@@ -520,12 +540,17 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
if contains("stopContainer", tt.executedSteps) {
|
if contains("stopContainer", tt.executedSteps) {
|
||||||
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "stopContainer")
|
executorOrder = append(executorOrder, "stopContainer")
|
||||||
|
require.NoError(t, ctx.Err())
|
||||||
|
_, bounded := ctx.Deadline()
|
||||||
|
require.True(t, bounded)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
jim.On("result", tt.result)
|
jim.On("result", tt.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tt.steps) > 0 {
|
||||||
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "closeContainer")
|
executorOrder = append(executorOrder, "closeContainer")
|
||||||
return nil
|
return nil
|
||||||
@@ -534,7 +559,14 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
|
|
||||||
executor := newJobExecutor(jim, sfm, rc)
|
executor := newJobExecutor(jim, sfm, rc)
|
||||||
err := executor(ctx)
|
err := executor(ctx)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
switch {
|
||||||
|
case tt.startError != nil:
|
||||||
|
require.ErrorIs(t, err, tt.startError)
|
||||||
|
case tt.cancelOnStart:
|
||||||
|
require.ErrorIs(t, err, context.Canceled)
|
||||||
|
default:
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
assert.Empty(t, rc.Run.Job().Outputs["bad"])
|
assert.Empty(t, rc.Run.Job().Outputs["bad"])
|
||||||
assert.Equal(t, tt.executedSteps, executorOrder)
|
assert.Equal(t, tt.executedSteps, executorOrder)
|
||||||
|
|
||||||
@@ -1049,6 +1081,7 @@ func TestApplyJobTimeout(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"empty", "runs-on: ubuntu-latest", false},
|
{"empty", "runs-on: ubuntu-latest", false},
|
||||||
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true},
|
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true},
|
||||||
|
{"zero ignored", "timeout-minutes: 0\nruns-on: ubuntu-latest", false},
|
||||||
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
|
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -51,11 +50,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
|||||||
rawLogger.Infof("shell: %s", shell)
|
rawLogger.Infof("shell: %s", shell)
|
||||||
}
|
}
|
||||||
|
|
||||||
env := map[string]string{}
|
env := mergeMaps(rc.containerSpec.Env, rc.GetEnv())
|
||||||
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
|
||||||
maps.Copy(env, jobContainer.Env)
|
|
||||||
}
|
|
||||||
maps.Copy(env, rc.GetEnv())
|
|
||||||
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
||||||
rc.ApplyExtraPath(ctx, &env)
|
rc.ApplyExtraPath(ctx, &env)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
@@ -34,9 +35,14 @@ func TestMaxParallelStrategy(t *testing.T) {
|
|||||||
expectedMaxParallel: 4,
|
expectedMaxParallel: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "max-parallel-10",
|
name: "max-parallel-10-clamped-to-combinations",
|
||||||
maxParallelString: "10",
|
maxParallelString: "10",
|
||||||
expectedMaxParallel: 10,
|
expectedMaxParallel: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max-parallel-invalid-falls-back-to-default",
|
||||||
|
maxParallelString: "tow",
|
||||||
|
expectedMaxParallel: 4,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +67,29 @@ func TestMaxParallelStrategy(t *testing.T) {
|
|||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.NotNil(t, matrixes)
|
assert.NotNil(t, matrixes)
|
||||||
assert.Len(t, matrixes, 5)
|
assert.Len(t, matrixes, 5)
|
||||||
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
|
assert.Equal(t, tt.expectedMaxParallel, maxParallelFor(job.Strategy, len(matrixes)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("deferred strategy", func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
if: false
|
||||||
|
strategy: ${{ fromJSON('{"max-parallel":2,"fail-fast":false,"matrix":{"os":["ubuntu","windows"]}}') }}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
job := workflow.Jobs["test"]
|
||||||
|
rawStrategy := model.CloneYamlNode(job.RawStrategy)
|
||||||
|
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
|
||||||
|
require.NotNil(t, job.Strategy)
|
||||||
|
assert.False(t, job.Strategy.GetFailFast())
|
||||||
|
assert.Equal(t, 2, maxParallelFor(job.Strategy, 5))
|
||||||
|
matrixes, err := job.GetMatrixes()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []map[string]any{{"os": "ubuntu"}, {"os": "windows"}}, matrixes)
|
||||||
|
assert.Equal(t, rawStrategy, job.RawStrategy)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
|
func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
|
||||||
@@ -81,4 +107,15 @@ func TestNewPlanExecutorInvalidMatrix(t *testing.T) {
|
|||||||
runner := &runnerImpl{config: &Config{}}
|
runner := &runnerImpl{config: &Config{}}
|
||||||
|
|
||||||
require.ErrorContains(t, runner.NewPlanExecutor(plan)(t.Context()), "could not get job matrix:")
|
require.ErrorContains(t, runner.NewPlanExecutor(plan)(t.Context()), "could not get job matrix:")
|
||||||
|
|
||||||
|
for _, strategy := range []string{
|
||||||
|
`${{ fromJSON('invalid') }}`,
|
||||||
|
`${{ '${{ inputs.unresolved }}' }}`,
|
||||||
|
} {
|
||||||
|
t.Run(strategy, func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n test:\n strategy: " + strategy))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Error(t, runner.NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "test"}}}}})(t.Context()))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+24
-16
@@ -28,8 +28,8 @@ import (
|
|||||||
// of upload-artifact.
|
// of upload-artifact.
|
||||||
//
|
//
|
||||||
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
|
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
|
||||||
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, which is what
|
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, so a patched cache
|
||||||
// keeps the runner out of the artifact path: the results URL still points at Gitea.
|
// client reaches the cache server by its own address rather than through the results origin.
|
||||||
//
|
//
|
||||||
// Either of these landing upstream makes this file deletable:
|
// Either of these landing upstream makes this file deletable:
|
||||||
//
|
//
|
||||||
@@ -105,37 +105,45 @@ func actionScriptPaths(dir string, action *model.Action) []string {
|
|||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchActions edits the toolkit in an action's bundles. The caller holds the action directory's
|
// patchActions returns a restore: the tree is shared, so a job with patching off gets no edits.
|
||||||
// clone lock, which is what keeps another job's checkout from resetting them before the copy.
|
func patchActions(ctx context.Context, scripts []string) func() {
|
||||||
func patchActions(ctx context.Context, scripts []string) {
|
restore := map[string][]byte{}
|
||||||
for _, script := range scripts {
|
for _, script := range scripts {
|
||||||
switch patched, err := patchBundle(script); {
|
original, err := patchBundle(script)
|
||||||
case err != nil:
|
if original != nil {
|
||||||
|
restore[script] = original // also when the write failed part way through
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
|
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
|
||||||
case patched:
|
}
|
||||||
common.Logger(ctx).Debugf("actions toolkit: patched %s", script)
|
}
|
||||||
|
return func() {
|
||||||
|
for script, original := range restore {
|
||||||
|
if err := os.WriteFile(script, original, 0o644); err != nil { //nolint:gosec // as the checkout wrote it
|
||||||
|
common.Logger(ctx).Warnf("actions toolkit: %s left patched in the shared copy: %v", script, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func patchBundle(script string) (bool, error) {
|
// patchBundle returns the bytes it replaced, or nil when it left the bundle alone.
|
||||||
|
func patchBundle(script string) ([]byte, error) {
|
||||||
info, err := os.Stat(script)
|
info, err := os.Stat(script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if info.Size() > maxBundleSize {
|
if info.Size() > maxBundleSize {
|
||||||
return false, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(script)
|
data, err := os.ReadFile(script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
patched, ok := patchedBundle(data)
|
patched, ok := patchedBundle(data)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
// No atomic write needed: every prepare checks the action out and hard resets it.
|
return data, os.WriteFile(script, patched, info.Mode().Perm())
|
||||||
return true, os.WriteFile(script, patched, info.Mode().Perm())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
||||||
|
|||||||
@@ -176,16 +176,16 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
|
|||||||
func TestPatchBundleIsIdempotent(t *testing.T) {
|
func TestPatchBundleIsIdempotent(t *testing.T) {
|
||||||
script := bundleFile(t, gateTSC)
|
script := bundleFile(t, gateTSC)
|
||||||
|
|
||||||
done, err := patchBundle(script)
|
original, err := patchBundle(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, done)
|
require.NotNil(t, original)
|
||||||
patched, err := os.ReadFile(script)
|
patched, err := os.ReadFile(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, gateOpened(string(patched)))
|
require.True(t, gateOpened(string(patched)))
|
||||||
|
|
||||||
done, err = patchBundle(script)
|
original, err = patchBundle(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, done, "a patched bundle is not patched again")
|
assert.Nil(t, original, "a patched bundle is not patched again")
|
||||||
again, err := os.ReadFile(script)
|
again, err := os.ReadFile(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, string(patched), string(again))
|
assert.Equal(t, string(patched), string(again))
|
||||||
@@ -194,9 +194,9 @@ func TestPatchBundleIsIdempotent(t *testing.T) {
|
|||||||
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
||||||
script := bundleFile(t, `console.log("checkout")`)
|
script := bundleFile(t, `console.log("checkout")`)
|
||||||
|
|
||||||
done, err := patchBundle(script)
|
original, err := patchBundle(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, done)
|
assert.Nil(t, original)
|
||||||
body, err := os.ReadFile(script)
|
body, err := os.ReadFile(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, `console.log("checkout")`, string(body))
|
assert.Equal(t, `console.log("checkout")`, string(body))
|
||||||
@@ -251,6 +251,9 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir(), "sub", "/var/run/act/actions/repo/sub"))
|
require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir(), "sub", "/var/run/act/actions/repo/sub"))
|
||||||
|
source, err := os.ReadFile(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gateTSC, string(source), "the shared copy is left as it was found")
|
||||||
return copied
|
return copied
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -20,28 +19,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||||
if !rc.Config.NoSkipCheckout {
|
localWorkflow, err := model.ParseReusableWorkflowUses(rc.Run.Job().Uses)
|
||||||
fullPath := rc.Run.Job().Uses
|
if err != nil {
|
||||||
|
return common.NewErrorExecutor(err)
|
||||||
fileName := path.Base(fullPath)
|
|
||||||
workflowDir := strings.TrimSuffix(fullPath, path.Join("/", fileName))
|
|
||||||
workflowDir = strings.TrimPrefix(workflowDir, "./")
|
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
|
||||||
// resolve the local workflow against the workspace root, not the process
|
|
||||||
// working directory, so it is found regardless of where the runner is invoked
|
|
||||||
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ./.gitea/workflows/wf.yml -> .gitea/workflows/wf.yml
|
if !rc.Config.NoSkipCheckout {
|
||||||
trimmedUses := strings.TrimPrefix(rc.Run.Job().Uses, "./")
|
// resolve the local workflow against the workspace root, not the process
|
||||||
// uses string format is {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}
|
// working directory, so it is found regardless of where the runner is invoked
|
||||||
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, trimmedUses, rc.Config.PresetGitHubContext.Sha)
|
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path)
|
||||||
|
}
|
||||||
|
|
||||||
remoteReusableWorkflow := newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses)
|
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
|
||||||
if remoteReusableWorkflow == nil {
|
|
||||||
return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
|
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, uses)
|
||||||
|
if err != nil {
|
||||||
|
return common.NewErrorExecutor(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
||||||
@@ -51,37 +44,27 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||||
uses := rc.Run.Job().Uses
|
remoteReusableWorkflow, err := newRemoteReusableWorkflow(rc.Config.GitHubInstance, rc.Run.Job().Uses)
|
||||||
|
if err != nil {
|
||||||
var remoteReusableWorkflow *remoteReusableWorkflow
|
return common.NewErrorExecutor(err)
|
||||||
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
|
|
||||||
remoteReusableWorkflow = newRemoteReusableWorkflowFromAbsoluteURL(uses)
|
|
||||||
if remoteReusableWorkflow == nil {
|
|
||||||
return common.NewErrorExecutor(fmt.Errorf("expected format http(s)://{domain}/{owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
remoteReusableWorkflow = newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses)
|
|
||||||
if remoteReusableWorkflow == nil {
|
|
||||||
return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref}
|
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref}
|
||||||
// instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using
|
// instead we will just use {owner}-{repo}@{ref} as our target directory. This should also improve performance when we are using
|
||||||
// multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again
|
// multiple reusable workflows from the same repository and ref since for each workflow we won't have to clone it again
|
||||||
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Owner, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
||||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
||||||
|
|
||||||
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
|
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +73,7 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
//
|
//
|
||||||
// Callers must not change remoteReusableWorkflow.URL, because:
|
// Callers must not change remoteReusableWorkflow.URL, because:
|
||||||
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
|
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
|
||||||
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat
|
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflow
|
||||||
//
|
//
|
||||||
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
||||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
||||||
@@ -153,64 +136,34 @@ func newReusableWorkflowRunner(rc *RunContext) (*runnerImpl, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type remoteReusableWorkflow struct {
|
type remoteReusableWorkflow struct {
|
||||||
URL string
|
*model.ReusableWorkflowUses
|
||||||
Org string
|
URL string
|
||||||
Repo string
|
|
||||||
Filename string
|
|
||||||
Ref string
|
|
||||||
|
|
||||||
GitPlatform string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *remoteReusableWorkflow) CloneURL() string {
|
func (r *remoteReusableWorkflow) CloneURL() string {
|
||||||
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
|
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
|
||||||
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
|
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
|
||||||
return fmt.Sprintf("%s/%s/%s", r.URL, r.Org, r.Repo)
|
return fmt.Sprintf("%s/%s/%s", r.URL, r.Owner, r.Repo)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Org, r.Repo)
|
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Owner, r.Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *remoteReusableWorkflow) FilePath() string {
|
var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
|
||||||
return fmt.Sprintf("./.%s/workflows/%s", r.GitPlatform, r.Filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow`
|
// newRemoteReusableWorkflow parses a remote `uses`, an absolute URL up to `{owner}/{repo}/.{git_platform}/workflows/` replaces baseURL
|
||||||
// workflows from `.gitea/workflows` and `.github/workflows` are supported
|
func newRemoteReusableWorkflow(baseURL, uses string) (*remoteReusableWorkflow, error) {
|
||||||
func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow {
|
if matches := absoluteReusableWorkflowURLRegex.FindStringSubmatch(uses); matches != nil {
|
||||||
// GitHub docs:
|
baseURL, uses = matches[1], matches[2]
|
||||||
// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses
|
|
||||||
r := regexp.MustCompile(`^([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`)
|
|
||||||
matches := r.FindStringSubmatch(uses)
|
|
||||||
if len(matches) != 6 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return &remoteReusableWorkflow{
|
parsed, err := model.ParseReusableWorkflowUses(uses)
|
||||||
Org: matches[1],
|
if err != nil {
|
||||||
Repo: matches[2],
|
return nil, err
|
||||||
GitPlatform: matches[3],
|
|
||||||
Filename: matches[4],
|
|
||||||
Ref: matches[5],
|
|
||||||
URL: url,
|
|
||||||
}
|
}
|
||||||
}
|
if parsed.IsLocal() {
|
||||||
|
return nil, fmt.Errorf("expected a remote reusable workflow, got %q", uses)
|
||||||
// For Gitea
|
|
||||||
// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow` from an absolute url
|
|
||||||
func newRemoteReusableWorkflowFromAbsoluteURL(uses string) *remoteReusableWorkflow {
|
|
||||||
r := regexp.MustCompile(`^(https?://.*)/([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`)
|
|
||||||
matches := r.FindStringSubmatch(uses)
|
|
||||||
if len(matches) != 7 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &remoteReusableWorkflow{
|
|
||||||
URL: matches[1],
|
|
||||||
Org: matches[2],
|
|
||||||
Repo: matches[3],
|
|
||||||
GitPlatform: matches[4],
|
|
||||||
Filename: matches[5],
|
|
||||||
Ref: matches[6],
|
|
||||||
}
|
}
|
||||||
|
return &remoteReusableWorkflow{ReusableWorkflowUses: parsed, URL: baseURL}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -106,6 +107,22 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) {
|
||||||
|
workdir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644))
|
||||||
|
|
||||||
|
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{Workdir: workdir},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
|
||||||
|
}
|
||||||
|
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotErrorIs(t, err, fs.ErrNotExist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
||||||
conf := &Config{
|
conf := &Config{
|
||||||
GitHubInstance: "gitea.example.net",
|
GitHubInstance: "gitea.example.net",
|
||||||
|
|||||||
+221
-286
@@ -16,6 +16,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
maps0 "maps"
|
maps0 "maps"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -34,8 +35,10 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/opencontainers/selinux/go-selinux"
|
"github.com/opencontainers/selinux/go-selinux"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"golang.org/x/text/encoding/unicode"
|
"golang.org/x/text/encoding/unicode"
|
||||||
"golang.org/x/text/transform"
|
"golang.org/x/text/transform"
|
||||||
@@ -61,17 +64,20 @@ type RunContext struct {
|
|||||||
ExprEval *expressionEvaluator
|
ExprEval *expressionEvaluator
|
||||||
JobContainer container.ExecutionsEnvironment
|
JobContainer container.ExecutionsEnvironment
|
||||||
serviceContainers []*serviceContainer
|
serviceContainers []*serviceContainer
|
||||||
|
containerSpec model.ContainerSpec // container:, resolved once with platformImage, zero without a job container
|
||||||
JobName string
|
JobName string
|
||||||
ActionPath string
|
ActionPath string
|
||||||
Parent *RunContext
|
Parent *RunContext
|
||||||
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
||||||
Masks []string
|
Masks []string
|
||||||
cleanUpJobContainer common.Executor
|
cleanUpJobContainer common.Executor
|
||||||
|
deferVolumeCleanup func(common.Executor)
|
||||||
caller *caller // job calling this RunContext (reusable workflows)
|
caller *caller // job calling this RunContext (reusable workflows)
|
||||||
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
|
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
|
||||||
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
|
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
|
||||||
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
|
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
|
||||||
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
|
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
|
||||||
|
jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown
|
||||||
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
||||||
// have already been created on the JobContainer. The runner sets up file-command files
|
// have already been created on the JobContainer. The runner sets up file-command files
|
||||||
// via JobContainer.Copy at the start of every phase, which truncates them — fine for
|
// via JobContainer.Copy at the start of every phase, which truncates them — fine for
|
||||||
@@ -80,10 +86,6 @@ type RunContext struct {
|
|||||||
// so writes from later phases and from composite sub-steps append to the same file.
|
// so writes from later phases and from composite sub-steps append to the same file.
|
||||||
// Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext.
|
// Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext.
|
||||||
summaryFileInitialized map[int]bool
|
summaryFileInitialized map[int]bool
|
||||||
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
|
|
||||||
// captured before execution so each matrix combo interpolates from the originals rather
|
|
||||||
// than from a sibling's already-resolved values written into the shared Job.Outputs.
|
|
||||||
outputTemplate map[string]string
|
|
||||||
// jobCancelled records that this job's run was cancelled (context.Canceled). It makes
|
// jobCancelled records that this job's run was cancelled (context.Canceled). It makes
|
||||||
// getJobContext report the "cancelled" status so cancelled()/always() evaluate the way
|
// getJobContext report the "cancelled" status so cancelled()/always() evaluate the way
|
||||||
// GitHub Actions does, letting cleanup and always() steps run while normal steps skip.
|
// GitHub Actions does, letting cleanup and always() steps run while normal steps skip.
|
||||||
@@ -97,6 +99,7 @@ type RunContext struct {
|
|||||||
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
|
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
|
||||||
jobNetworkName string
|
jobNetworkName string
|
||||||
dockerProxy *container.DockerProxy
|
dockerProxy *container.DockerProxy
|
||||||
|
hadDockerProxy bool
|
||||||
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
||||||
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
||||||
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
|
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
|
||||||
@@ -252,7 +255,7 @@ func (rc *RunContext) validVolumes() []string {
|
|||||||
volumes = append(volumes, sharedToolCacheVolume)
|
volumes = append(volumes, sharedToolCacheVolume)
|
||||||
}
|
}
|
||||||
if rc.Config.BindWorkdir {
|
if rc.Config.BindWorkdir {
|
||||||
volumes = append(volumes, rc.Config.Workdir)
|
volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
|
||||||
}
|
}
|
||||||
if rc.dockerProxy != nil {
|
if rc.dockerProxy != nil {
|
||||||
volumes = append(volumes, rc.dockerProxy.Socket)
|
volumes = append(volumes, rc.dockerProxy.Socket)
|
||||||
@@ -262,6 +265,13 @@ func (rc *RunContext) validVolumes() []string {
|
|||||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) workdirMountRoot() string {
|
||||||
|
if rc.Config.PresetGitHubContext != nil {
|
||||||
|
return filepath.Dir(filepath.Dir(rc.Config.Workdir)) // only the daemon presets, its workdir is <parent>/<owner>/<repo>
|
||||||
|
}
|
||||||
|
return rc.Config.Workdir
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) jobDockerSocket() string {
|
func (rc *RunContext) jobDockerSocket() string {
|
||||||
if rc.dockerProxy != nil {
|
if rc.dockerProxy != nil {
|
||||||
return rc.dockerProxy.Socket
|
return rc.dockerProxy.Socket
|
||||||
@@ -270,20 +280,10 @@ func (rc *RunContext) jobDockerSocket() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) startDockerProxy(ctx context.Context) {
|
func (rc *RunContext) startDockerProxy(ctx context.Context) {
|
||||||
daemonSocket := rc.containerDaemonSocket()
|
if !filepath.IsAbs(strings.TrimPrefix(rc.containerDaemonSocket(), "unix://")) || common.Dryrun(ctx) {
|
||||||
if daemonSocket == "-" || strings.HasPrefix(strings.ToLower(daemonSocket), "npipe://") {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dir := container.DockerProxyDir(ctx)
|
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
|
||||||
if dir == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxy, err := container.StartDockerProxy(getDockerDaemonSocketMountPath(daemonSocket), dir, rc.jobContainerName())
|
|
||||||
if err != nil {
|
|
||||||
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rc.dockerProxy = proxy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||||
@@ -334,26 +334,12 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
|
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
|
|
||||||
var volumes []string
|
|
||||||
if job := rc.Run.Job(); job != nil {
|
|
||||||
if container := job.Container(); container != nil {
|
|
||||||
for _, v := range container.Volumes {
|
|
||||||
if rc.ExprEval != nil {
|
|
||||||
var err error
|
|
||||||
if v, err = rc.ExprEval.Interpolate(context.Background(), v); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("unable to interpolate container.volumes: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
volumes = append(volumes, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// the runner's own mounts below yield to the targets the job claims
|
// the runner's own mounts below yield to the targets the job claims
|
||||||
binds, mounts, claimed := splitVolumes(volumes)
|
binds, mounts, claimed := splitVolumes(rc.containerSpec.Volumes)
|
||||||
|
|
||||||
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
|
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
|
||||||
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
|
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
|
||||||
@@ -366,6 +352,11 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
|
|||||||
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||||
|
|
||||||
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||||
|
source := rc.workdirMountRoot()
|
||||||
|
target := ext.ToContainerPath(source)
|
||||||
|
if claimed[target] {
|
||||||
|
source, target = rc.Config.Workdir, workdir
|
||||||
|
}
|
||||||
if rc.Config.BindWorkdir {
|
if rc.Config.BindWorkdir {
|
||||||
bindModifiers := ""
|
bindModifiers := ""
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
@@ -374,13 +365,13 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
|
|||||||
if selinux.GetEnabled() {
|
if selinux.GetEnabled() {
|
||||||
bindModifiers = ":z"
|
bindModifiers = ":z"
|
||||||
}
|
}
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers))
|
binds = append(binds, fmt.Sprintf("%s:%s%s", source, target, bindModifiers))
|
||||||
} else {
|
} else {
|
||||||
mounts[name] = workdir
|
mounts[name] = target
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return binds, mounts, nil
|
return binds, mounts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) startHostEnvironment() common.Executor {
|
func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||||
@@ -468,13 +459,20 @@ func printStartJobContainerGroup(ctx context.Context, image, name, network strin
|
|||||||
// newContainer is a variable so tests can substitute a container that needs no Docker daemon.
|
// newContainer is a variable so tests can substitute a container that needs no Docker daemon.
|
||||||
var newContainer = container.NewContainer
|
var newContainer = container.NewContainer
|
||||||
|
|
||||||
|
type jobVolumeCleanupKey struct{}
|
||||||
|
|
||||||
|
func WithJobVolumeCleanup(ctx context.Context, deferCleanup func(common.Executor)) context.Context {
|
||||||
|
return context.WithValue(ctx, jobVolumeCleanupKey{}, deferCleanup)
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) startJobContainer() common.Executor {
|
func (rc *RunContext) startJobContainer() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
|
rc.deferVolumeCleanup, _ = ctx.Value(jobVolumeCleanupKey{}).(func(common.Executor))
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
image := rc.platformImage
|
image := rc.platformImage
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
|
|
||||||
username, password, err := rc.handleCredentials(ctx)
|
username, password, err := registryCredentials(rc.containerSpec.Credentials, "container.")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to handle credentials: %s", err)
|
return fmt.Errorf("failed to handle credentials: %s", err)
|
||||||
}
|
}
|
||||||
@@ -483,93 +481,69 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
||||||
// We need to set the container name to the environment variable.
|
// We need to set the container name to the environment variable.
|
||||||
rc.Env["JOB_CONTAINER_NAME"] = name
|
rc.Env["JOB_CONTAINER_NAME"] = name
|
||||||
rc.startDockerProxy(ctx)
|
|
||||||
|
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
|
|
||||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
||||||
|
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
|
||||||
binds, mounts, err := rc.GetBindsAndMounts()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
|
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
|
||||||
// if using service containers, will create a new network for the containers.
|
// if using service containers, will create a new network for the containers.
|
||||||
// and it will be removed after at last.
|
// and it will be removed after at last.
|
||||||
networkName, createAndDeleteNetwork := rc.networkNameForGitea()
|
networkName, createAndDeleteNetwork := rc.networkNameForGitea()
|
||||||
|
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false)
|
||||||
|
|
||||||
// add service containers
|
var services map[string]*model.ContainerSpec
|
||||||
for serviceID, spec := range rc.Run.Job().Services {
|
if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.ExprEval.shared(ctx).EvaluateYamlNode, &services); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for serviceID, spec := range services {
|
||||||
|
if spec == nil {
|
||||||
|
return fmt.Errorf("service %s has no container definition", serviceID)
|
||||||
|
}
|
||||||
// GitHub compatibility: skip services whose image evaluates to an
|
// GitHub compatibility: skip services whose image evaluates to an
|
||||||
// empty string, enabling conditional services via expressions
|
// empty string, enabling conditional services via expressions
|
||||||
serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image)
|
if spec.Image == "" {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate service %s image: %w", serviceID, err)
|
|
||||||
}
|
|
||||||
if serviceImage == "" {
|
|
||||||
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// interpolate env
|
envs := make([]string, 0, len(spec.Env)+len(rc.Config.ProxyEnv))
|
||||||
interpolatedEnvs := make(map[string]string, len(spec.Env)+len(rc.Config.ProxyEnv))
|
|
||||||
// a service reaches the internet the way the job does; its own env still wins
|
// a service reaches the internet the way the job does; its own env still wins
|
||||||
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
|
for k, v := range mergeMaps(rc.Config.ProxyEnv, spec.Env) {
|
||||||
for k, v := range spec.Env {
|
|
||||||
if interpolatedEnvs[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate service %s env %s: %w", serviceID, k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
envs := make([]string, 0, len(interpolatedEnvs))
|
|
||||||
for k, v := range interpolatedEnvs {
|
|
||||||
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
|
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
// interpolate cmd
|
|
||||||
interpolatedCmd := make([]string, len(spec.Cmd))
|
|
||||||
for i, v := range spec.Cmd {
|
|
||||||
if interpolatedCmd[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate service %s command: %w", serviceID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// keep these local: reusing username/password would overwrite the
|
// keep these local: reusing username/password would overwrite the
|
||||||
// credentials the job container is pulled with further down
|
// credentials the job container is pulled with further down
|
||||||
serviceUsername, servicePassword, err := rc.interpolateCredentials(ctx, spec.Credentials, "")
|
serviceUsername, servicePassword, err := registryCredentials(spec.Credentials, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
||||||
}
|
}
|
||||||
|
cmd := spec.Cmd
|
||||||
interpolatedVolumes := make([]string, len(spec.Volumes))
|
if len(cmd) == 0 && spec.Command != "" {
|
||||||
for i, volume := range spec.Volumes {
|
if cmd, err = shellquote.Split(spec.Command); err != nil {
|
||||||
if interpolatedVolumes[i], err = rc.ExprEval.Interpolate(ctx, volume); err != nil {
|
return fmt.Errorf("failed to parse service %s command: %w", serviceID, err)
|
||||||
return fmt.Errorf("unable to interpolate service %s volumes: %w", serviceID, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes)
|
var entrypoint []string
|
||||||
|
if spec.Entrypoint != "" {
|
||||||
interpolatedPorts := make([]string, len(spec.Ports))
|
entrypoint = []string{spec.Entrypoint}
|
||||||
for i, port := range spec.Ports {
|
|
||||||
if interpolatedPorts[i], err = rc.ExprEval.Interpolate(ctx, port); err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate service %s ports: %w", serviceID, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
|
|
||||||
|
serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes)
|
||||||
|
|
||||||
|
exposedPorts, portBindings, err := nat.ParsePortSpecs(spec.Ports)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
|
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
|
||||||
}
|
}
|
||||||
serviceOptions, err := rc.ExprEval.Interpolate(ctx, spec.Options)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate service %s options: %w", serviceID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
|
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
|
||||||
c := newContainer(&container.NewContainerInput{
|
c := newContainer(&container.NewContainerInput{
|
||||||
Name: serviceContainerName,
|
Name: serviceContainerName,
|
||||||
Image: serviceImage,
|
Image: spec.Image,
|
||||||
Username: serviceUsername,
|
Username: serviceUsername,
|
||||||
Password: servicePassword,
|
Password: servicePassword,
|
||||||
Cmd: interpolatedCmd,
|
Entrypoint: entrypoint,
|
||||||
|
Cmd: cmd,
|
||||||
Env: envs,
|
Env: envs,
|
||||||
Mounts: serviceMounts,
|
Mounts: serviceMounts,
|
||||||
Binds: serviceBinds,
|
Binds: serviceBinds,
|
||||||
@@ -579,7 +553,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
||||||
WorkflowOptions: serviceOptions,
|
WorkflowOptions: spec.Options,
|
||||||
NetworkMode: networkName,
|
NetworkMode: networkName,
|
||||||
NetworkAliases: []string{serviceID},
|
NetworkAliases: []string{serviceID},
|
||||||
ExposedPorts: exposedPorts,
|
ExposedPorts: exposedPorts,
|
||||||
@@ -587,19 +561,14 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket
|
ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
})
|
||||||
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c})
|
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: spec.Image, container: c})
|
||||||
}
|
}
|
||||||
|
|
||||||
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
|
|
||||||
|
|
||||||
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
||||||
jobContainerNetwork := networkName
|
jobContainerNetwork := networkName
|
||||||
|
|
||||||
workflowOptions, err := rc.workflowOptions(ctx)
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
if err != nil {
|
containerInput := &container.NewContainerInput{
|
||||||
return err
|
|
||||||
}
|
|
||||||
rc.JobContainer = newContainer(&container.NewContainerInput{
|
|
||||||
Cmd: nil,
|
Cmd: nil,
|
||||||
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
||||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
||||||
@@ -608,37 +577,44 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
Password: password,
|
Password: password,
|
||||||
Name: name,
|
Name: name,
|
||||||
Env: envList,
|
Env: envList,
|
||||||
Mounts: mounts,
|
|
||||||
NetworkMode: jobContainerNetwork,
|
NetworkMode: jobContainerNetwork,
|
||||||
NetworkAliases: []string{rc.Name},
|
NetworkAliases: []string{rc.Name},
|
||||||
Binds: binds,
|
|
||||||
Stdout: logWriter,
|
Stdout: logWriter,
|
||||||
Stderr: logWriter,
|
Stderr: logWriter,
|
||||||
Privileged: rc.Config.Privileged,
|
Privileged: rc.Config.Privileged,
|
||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
RunnerOptions: rc.Config.ContainerOptions,
|
RunnerOptions: rc.Config.ContainerOptions,
|
||||||
WorkflowOptions: workflowOptions,
|
WorkflowOptions: rc.containerSpec.Options,
|
||||||
AutoRemove: true,
|
AutoRemove: true,
|
||||||
ValidVolumes: rc.validVolumes(),
|
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
}
|
||||||
|
rc.JobContainer = newContainer(containerInput)
|
||||||
if rc.JobContainer == nil {
|
if rc.JobContainer == nil {
|
||||||
return errors.New("failed to create job container")
|
return errors.New("failed to create job container")
|
||||||
}
|
}
|
||||||
|
defer printStartJobContainerGroup(ctx, image, name, networkName)()
|
||||||
|
if err := common.NewPipelineExecutor(
|
||||||
|
rc.cleanupJobResources(networkName, createAndDeleteNetwork, true),
|
||||||
|
rc.pullServicesImages(rc.Config.ForcePull),
|
||||||
|
rc.JobContainer.Pull(rc.Config.ForcePull),
|
||||||
|
)(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc.startDockerProxy(ctx)
|
||||||
|
containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts()
|
||||||
|
containerInput.ValidVolumes = rc.validVolumes()
|
||||||
|
|
||||||
rc.jobNetworkName = networkName
|
rc.jobNetworkName = networkName
|
||||||
|
|
||||||
defer printStartJobContainerGroup(ctx, image, name, networkName)()
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
rc.pullServicesImages(rc.Config.ForcePull),
|
|
||||||
rc.JobContainer.Pull(rc.Config.ForcePull),
|
|
||||||
rc.stopJobContainer(),
|
|
||||||
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
|
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
|
||||||
IfBool(createAndDeleteNetwork),
|
IfBool(createAndDeleteNetwork),
|
||||||
|
container.NewDockerNetworkConnectExecutor(networkName, rc.Config.CacheContainer).
|
||||||
|
IfBool(rc.Config.CacheContainer != ""),
|
||||||
rc.startServiceContainers(),
|
rc.startServiceContainers(),
|
||||||
rc.reportUnstartedServices(),
|
rc.reportUnstartedServices(),
|
||||||
rc.waitForServiceContainers(),
|
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
|
||||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
rc.JobContainer.Start(false),
|
rc.JobContainer.Start(false),
|
||||||
rc.captureJobContainerInfo(),
|
rc.captureJobContainerInfo(),
|
||||||
@@ -651,6 +627,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
Mode: 0o666,
|
Mode: 0o666,
|
||||||
Body: "",
|
Body: "",
|
||||||
}),
|
}),
|
||||||
|
rc.waitForServiceContainers(),
|
||||||
)(ctx)
|
)(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -663,47 +640,71 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupJobResources removes everything the job created, continuing past failures.
|
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork, preclean bool) common.Executor {
|
||||||
// Only job container and volume errors are returned, the rest are logged.
|
|
||||||
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
removeJobContainer := rc.JobContainer != nil
|
errs := []error{rc.closeDockerProxy(ctx)}
|
||||||
|
if rc.JobContainer != nil {
|
||||||
var errs []error
|
|
||||||
if removeJobContainer {
|
|
||||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
||||||
}
|
}
|
||||||
if len(rc.serviceContainers) > 0 {
|
if len(rc.serviceContainers) > 0 {
|
||||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
errs = append(errs, rc.stopServiceContainers()(ctx))
|
||||||
logger.Errorf("Error while cleaning services: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if rc.dockerProxy != nil {
|
if !common.Dryrun(ctx) && (preclean || rc.hadDockerProxy) {
|
||||||
if err := rc.dockerProxy.Close(ctx); err != nil {
|
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
|
||||||
logger.Errorf("Error while removing what the job created: %v", err)
|
|
||||||
}
|
|
||||||
rc.dockerProxy = nil
|
|
||||||
}
|
}
|
||||||
if removeJobContainer {
|
if preclean || rc.deferVolumeCleanup == nil {
|
||||||
// after the containers using them, services can hold these via `--volumes-from`
|
errs = append(errs, rc.cleanupJobVolumes(ctx))
|
||||||
name := rc.jobContainerName()
|
} else {
|
||||||
errs = append(errs,
|
rc.deferVolumeCleanup(rc.cleanupJobVolumes)
|
||||||
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
|
|
||||||
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
|
|
||||||
}
|
}
|
||||||
if createAndDeleteNetwork {
|
if createAndDeleteNetwork {
|
||||||
// last, once every container has detached
|
|
||||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||||
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
errs = append(errs, container.NewDockerNetworkDisconnectExecutor(networkName, rc.Config.CacheContainer).
|
||||||
logger.Errorf("Error while cleaning network: %v", err)
|
IfBool(rc.Config.CacheContainer != "")(ctx))
|
||||||
}
|
errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
|
||||||
}
|
}
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) createJobVolumes(ctx context.Context, mounts map[string]string) error {
|
||||||
|
runnerUUID := rc.Config.ContainerNetworkCreateOptions.RunnerUUID
|
||||||
|
if runnerUUID == "" || common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := rc.jobContainerName()
|
||||||
|
volumeNames := []string{name, name + "-env"}
|
||||||
|
if _, ok := mounts[name]; !ok { // the workspace is a bind mount, the env volume is always ours
|
||||||
|
volumeNames = volumeNames[1:]
|
||||||
|
}
|
||||||
|
return container.CreateJobVolumes(ctx, runnerUUID, volumeNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) cleanupJobVolumes(ctx context.Context) error {
|
||||||
|
if rc.JobContainer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := rc.jobContainerName()
|
||||||
|
return errors.Join(
|
||||||
|
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
|
||||||
|
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) closeDockerProxy(ctx context.Context) error {
|
||||||
|
if rc.dockerProxy == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rc.hadDockerProxy = true
|
||||||
|
err := rc.dockerProxy.Close(ctx)
|
||||||
|
rc.dockerProxy = nil
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("close docker proxy: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) {
|
func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) {
|
||||||
if len(rc.ExtraPath) > 0 {
|
if len(rc.ExtraPath) > 0 {
|
||||||
path := rc.JobContainer.GetPathVariableName()
|
path := rc.JobContainer.GetPathVariableName()
|
||||||
@@ -763,7 +764,6 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopJobContainer removes the job container (if it exists) and its volume (if it exists)
|
|
||||||
func (rc *RunContext) stopJobContainer() common.Executor {
|
func (rc *RunContext) stopJobContainer() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if rc.cleanUpJobContainer != nil {
|
if rc.cleanUpJobContainer != nil {
|
||||||
@@ -788,7 +788,6 @@ func (rc *RunContext) startServiceContainers() common.Executor {
|
|||||||
execs := []common.Executor{}
|
execs := []common.Executor{}
|
||||||
for _, svc := range rc.serviceContainers {
|
for _, svc := range rc.serviceContainers {
|
||||||
execs = append(execs, common.NewPipelineExecutor(
|
execs = append(execs, common.NewPipelineExecutor(
|
||||||
svc.container.Pull(false),
|
|
||||||
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
svc.container.Start(false),
|
svc.container.Start(false),
|
||||||
))
|
))
|
||||||
@@ -800,19 +799,23 @@ func (rc *RunContext) startServiceContainers() common.Executor {
|
|||||||
func (rc *RunContext) stopServiceContainers() common.Executor {
|
func (rc *RunContext) stopServiceContainers() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
execs := []common.Executor{}
|
execs := []common.Executor{}
|
||||||
for _, svc := range rc.serviceContainers {
|
errs := make([]error, len(rc.serviceContainers))
|
||||||
execs = append(execs, svc.container.Remove().Finally(svc.container.Close()))
|
for index, svc := range rc.serviceContainers {
|
||||||
|
execs = append(execs, func(ctx context.Context) error {
|
||||||
|
if err := errors.Join(svc.container.Remove()(ctx), svc.container.Close()(ctx)); err != nil {
|
||||||
|
errs[index] = fmt.Errorf("clean service %s: %w", svc.name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return common.NewParallelExecutor(len(execs), execs...)(ctx)
|
errs = append(errs, common.NewParallelExecutor(len(execs), execs...)(ctx))
|
||||||
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const defaultServiceReadyTimeout = 5 * time.Minute
|
||||||
defaultServiceReadyTimeout = 5 * time.Minute
|
|
||||||
serviceReadyPollMax = 32 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
var serviceReadyPollInterval = 2 * time.Second // a variable so tests need not wait
|
const serviceReadyPollInterval = time.Second
|
||||||
|
|
||||||
// reportUnstartedServices logs a service that did not start. The steps that need it
|
// reportUnstartedServices logs a service that did not start. The steps that need it
|
||||||
// report it better than the runner can, so the job carries on.
|
// report it better than the runner can, so the job carries on.
|
||||||
@@ -872,7 +875,7 @@ func (rc *RunContext) waitForServiceContainers() common.Executor {
|
|||||||
// ready at once and one that exited is left to the steps that need it.
|
// ready at once and one that exited is left to the steps that need it.
|
||||||
func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error {
|
func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error {
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
interval := serviceReadyPollInterval
|
loggedStarting := false
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -902,12 +905,14 @@ func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rawLogger.Infof("%s service is starting, waiting %d seconds before checking again.", svc.name, int(interval.Seconds()))
|
if !loggedStarting {
|
||||||
|
rawLogger.Infof("%s service is starting.", svc.name)
|
||||||
|
loggedStarting = true
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done(): // reported at the top of the loop
|
case <-ctx.Done(): // reported at the top of the loop
|
||||||
case <-time.After(interval):
|
case <-time.After(serviceReadyPollInterval):
|
||||||
}
|
}
|
||||||
interval = min(interval*2, serviceReadyPollMax)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,7 +954,10 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rc.jobContainerID = info.ID
|
rc.jobContainerID = info.ID
|
||||||
if source := info.Mounts[rc.githubWorkspace()]; source != "" {
|
if rc.dockerProxy != nil {
|
||||||
|
rc.dockerProxy.SetMounts(info.Mounts)
|
||||||
|
}
|
||||||
|
if source := info.DaemonPath(rc.githubWorkspace()); source != "" {
|
||||||
rc.Env["GITEA_DOCKER_WORKSPACE"] = source
|
rc.Env["GITEA_DOCKER_WORKSPACE"] = source
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -988,30 +996,16 @@ func lockJob(job *model.Job) func() {
|
|||||||
|
|
||||||
func (rc *RunContext) interpolateOutputs() common.Executor {
|
func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
|
||||||
job := rc.Run.Job()
|
job := rc.Run.Job()
|
||||||
// Matrix combinations share this Job and its Outputs map. Interpolate from this combo's
|
var outputs map[string]string
|
||||||
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
|
if err := model.DecodeEvaluated("job outputs", job.RawOutputs, rc.NewExpressionEvaluator(ctx).shared(ctx).EvaluateYamlNode, &outputs); err != nil {
|
||||||
// with its own resolved values (last wins, as on GitHub) instead of the first combo's
|
return err
|
||||||
// resolved values freezing the shared template against later combos.
|
|
||||||
// Resolved up front so one failure publishes none of them, as GitHub does.
|
|
||||||
outputs := make(map[string]string, len(rc.outputTemplate))
|
|
||||||
var err error
|
|
||||||
for k, v := range rc.outputTemplate {
|
|
||||||
if outputs[k], err = ee.Interpolate(ctx, v); err != nil {
|
|
||||||
err = fmt.Errorf("failed to evaluate job output %q: %w", k, err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Matrix combinations share the job, so as on GitHub a failure publishes nothing and the last non-empty value wins.
|
||||||
|
maps0.DeleteFunc(outputs, func(_, value string) bool { return value == "" })
|
||||||
defer lockJob(job)()
|
defer lockJob(job)()
|
||||||
for k := range rc.outputTemplate {
|
job.Outputs = mergeMaps(job.Outputs, outputs)
|
||||||
if err != nil {
|
return nil
|
||||||
job.Outputs[k] = ""
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
job.Outputs[k] = outputs[k]
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,17 +1027,13 @@ func (rc *RunContext) startContainer() common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
|
func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
|
||||||
if rc.cleanUpJobContainer == nil {
|
cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||||
return
|
defer cancel()
|
||||||
|
cleanup := rc.cleanUpJobContainer
|
||||||
|
if cleanup == nil {
|
||||||
|
cleanup = rc.closeDockerProxy
|
||||||
}
|
}
|
||||||
cleanCtx := ctx
|
if err := cleanup(cleanCtx); err != nil {
|
||||||
if ctx.Err() != nil {
|
|
||||||
// the start likely failed because ctx was cancelled, detach so teardown still runs
|
|
||||||
var cancel context.CancelFunc
|
|
||||||
cleanCtx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
if err := rc.cleanUpJobContainer(cleanCtx); err != nil {
|
|
||||||
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
|
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1118,9 +1108,7 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
res, err := rc.isEnabled(ctx)
|
res, err := rc.isEnabled(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Record the failure so a job whose if-expression fails to evaluate
|
// Record the failure so a job that cannot start gets a stop time. rc.caller is only set for reusable workflows.
|
||||||
// gets a result (and therefore a stop time) instead of being left
|
|
||||||
// unfinished. rc.caller is only set for reusable workflows.
|
|
||||||
rc.result("failure")
|
rc.result("failure")
|
||||||
if rc.caller != nil { // For Gitea
|
if rc.caller != nil { // For Gitea
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "failure")
|
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "failure")
|
||||||
@@ -1134,18 +1122,6 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) containerImage(ctx context.Context) (string, error) {
|
|
||||||
c := rc.Run.Job().Container()
|
|
||||||
if c == nil {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
image, err := rc.ExprEval.Interpolate(ctx, c.Image)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("unable to interpolate container.image: %w", err)
|
|
||||||
}
|
|
||||||
return image, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
|
func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
|
||||||
if rc.Run.Job().RunsOn() == nil {
|
if rc.Run.Job().RunsOn() == nil {
|
||||||
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
|
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
|
||||||
@@ -1183,26 +1159,37 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
|||||||
return model.RunsOnFromNode(rawRunsOn)
|
return model.RunsOnFromNode(rawRunsOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePlatformImage evaluates the job's image once, so every consumer sees the image the job started with.
|
// resolvePlatformImage evaluates the job's container once for every consumer, ignoring one without an image as GitHub does.
|
||||||
func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
|
func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
|
||||||
image, err := rc.containerImage(ctx)
|
withoutEnv, _ := splitContainerEnv(rc.Run.Job().RawContainer)
|
||||||
if err == nil && image == "" {
|
var spec *model.ContainerSpec
|
||||||
image, err = rc.runsOnImage(ctx)
|
if err := model.DecodeEvaluated("container", withoutEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &spec); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
if spec != nil && spec.Image != "" {
|
||||||
|
rc.containerSpec, rc.platformImage = *spec, spec.Image
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
image, err := rc.runsOnImage(ctx)
|
||||||
rc.platformImage = image
|
rc.platformImage = image
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) {
|
// splitContainerEnv takes `env` out of a container mapping, since env reads the job env that resolves after the job environment starts.
|
||||||
c := rc.Run.Job().Container()
|
func splitContainerEnv(container yaml.Node) (withoutEnv, env yaml.Node) {
|
||||||
if c == nil {
|
if container.Kind != yaml.MappingNode {
|
||||||
return "", nil
|
return container, env
|
||||||
}
|
}
|
||||||
options, err := rc.ExprEval.Interpolate(ctx, c.Options)
|
withoutEnv = container
|
||||||
if err != nil {
|
withoutEnv.Content = nil
|
||||||
return "", fmt.Errorf("unable to interpolate container.options: %w", err)
|
for i := 0; i+1 < len(container.Content); i += 2 {
|
||||||
|
if container.Content[i].Value == "env" {
|
||||||
|
env = *container.Content[i+1]
|
||||||
|
} else {
|
||||||
|
withoutEnv.Content = append(withoutEnv.Content, container.Content[i:i+2]...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return options, nil
|
return withoutEnv, env
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
||||||
@@ -1212,7 +1199,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
|||||||
jobType, jobTypeErr := job.Type()
|
jobType, jobTypeErr := job.Type()
|
||||||
|
|
||||||
if runJobErr != nil {
|
if runJobErr != nil {
|
||||||
return false, fmt.Errorf("if-expression %q evaluation failed: %s", job.If.Value, runJobErr)
|
l.Errorf("Error when evaluating 'if' for job '%s'. %v", rc.Run.JobID, runJobErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jobType == model.JobTypeInvalid {
|
if jobType == model.JobTypeInvalid {
|
||||||
@@ -1289,21 +1276,26 @@ func trimToLen(s string, l int) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) getJobContext() *model.JobContext {
|
// ownStatus is the result of this context's own steps, which for a composite action is its action status
|
||||||
jobStatus := "success"
|
func (rc *RunContext) ownStatus() string {
|
||||||
if rc.jobFailed {
|
if rc.jobFailed {
|
||||||
jobStatus = "failure"
|
return "failure"
|
||||||
}
|
}
|
||||||
for _, stepStatus := range rc.StepResults {
|
for _, stepStatus := range rc.StepResults {
|
||||||
if stepStatus.Conclusion == model.StepStatusFailure {
|
if stepStatus.Conclusion == model.StepStatusFailure {
|
||||||
jobStatus = "failure"
|
return "failure"
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) getJobContext() *model.JobContext {
|
||||||
|
job := rc.topLevelRunContext()
|
||||||
|
jobStatus := job.ownStatus()
|
||||||
// A cancelled run takes precedence over success/failure so cancelled() is true and
|
// A cancelled run takes precedence over success/failure so cancelled() is true and
|
||||||
// success()/failure() are false, matching GitHub Actions: on cancellation only
|
// success()/failure() are false, matching GitHub Actions: on cancellation only
|
||||||
// always() and cancelled() steps run.
|
// always() and cancelled() steps run.
|
||||||
if rc.jobCancelled {
|
if job.jobCancelled {
|
||||||
jobStatus = "cancelled"
|
jobStatus = "cancelled"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1311,17 +1303,17 @@ func (rc *RunContext) getJobContext() *model.JobContext {
|
|||||||
Status: jobStatus,
|
Status: jobStatus,
|
||||||
Services: map[string]model.JobService{}, // an empty map, never null
|
Services: map[string]model.JobService{}, // an empty map, never null
|
||||||
}
|
}
|
||||||
if rc.jobContainerID != "" {
|
if job.jobContainerID != "" {
|
||||||
jobContext.Container.ID = rc.jobContainerID
|
jobContext.Container.ID = job.jobContainerID
|
||||||
jobContext.Container.Network = rc.jobNetworkName
|
jobContext.Container.Network = job.jobNetworkName
|
||||||
}
|
}
|
||||||
for _, svc := range rc.serviceContainers {
|
for _, svc := range job.serviceContainers {
|
||||||
if svc.info == nil {
|
if svc.info == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
jobContext.Services[svc.name] = model.JobService{
|
jobContext.Services[svc.name] = model.JobService{
|
||||||
ID: svc.info.ID,
|
ID: svc.info.ID,
|
||||||
Network: rc.jobNetworkName,
|
Network: job.jobNetworkName,
|
||||||
Ports: svc.info.Ports,
|
Ports: svc.info.Ports,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1416,22 +1408,11 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
|
|
||||||
{ // Adapt to Gitea
|
{ // Adapt to Gitea
|
||||||
if preset := rc.Config.PresetGitHubContext; preset != nil {
|
if preset := rc.Config.PresetGitHubContext; preset != nil {
|
||||||
ghc.Event = preset.Event
|
merged := *preset
|
||||||
ghc.RunID = preset.RunID
|
merged.Workflow, merged.Job, merged.Workspace, merged.EventPath = ghc.Workflow, ghc.Job, ghc.Workspace, ghc.EventPath
|
||||||
ghc.RunNumber = preset.RunNumber
|
merged.Action, merged.ActionPath, merged.ActionRepository, merged.ActionRef = ghc.Action, ghc.ActionPath, ghc.ActionRepository, ghc.ActionRef
|
||||||
ghc.RunAttempt = preset.RunAttempt
|
merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
|
||||||
ghc.Actor = preset.Actor
|
*ghc = merged
|
||||||
ghc.Repository = preset.Repository
|
|
||||||
ghc.EventName = preset.EventName
|
|
||||||
ghc.Sha = preset.Sha
|
|
||||||
ghc.Ref = preset.Ref
|
|
||||||
ghc.RefName = preset.RefName
|
|
||||||
ghc.RefType = preset.RefType
|
|
||||||
ghc.HeadRef = preset.HeadRef
|
|
||||||
ghc.BaseRef = preset.BaseRef
|
|
||||||
ghc.Token = preset.Token
|
|
||||||
ghc.RepositoryOwner = preset.RepositoryOwner
|
|
||||||
ghc.RetentionDays = preset.RetentionDays
|
|
||||||
|
|
||||||
instance := rc.Config.GitHubInstance
|
instance := rc.Config.GitHubInstance
|
||||||
if !strings.HasPrefix(instance, "http://") &&
|
if !strings.HasPrefix(instance, "http://") &&
|
||||||
@@ -1508,12 +1489,7 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
|
|||||||
if step.Type() != model.StepTypeUsesActionRemote {
|
if step.Type() != model.StepTypeUsesActionRemote {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
remoteAction := newRemoteAction(step.Uses)
|
if uses, err := model.ParseActionUses(step.Uses); err != nil || uses.Owner != "actions" || uses.Repo != "checkout" {
|
||||||
if remoteAction == nil {
|
|
||||||
// IsCheckout() will nil panic if we dont bail out early
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !remoteAction.IsCheckout() {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1526,23 +1502,6 @@ func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func nestedMapLookup(m map[string]any, ks ...string) (rval any) {
|
|
||||||
var ok bool
|
|
||||||
|
|
||||||
if len(ks) == 0 { // degenerate input
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if rval, ok = m[ks[0]]; !ok {
|
|
||||||
return nil
|
|
||||||
} else if len(ks) == 1 { // we've reached the final key
|
|
||||||
return rval
|
|
||||||
} else if m, ok = rval.(map[string]any); !ok {
|
|
||||||
return nil
|
|
||||||
} else { // 1+ more keys
|
|
||||||
return nestedMapLookup(m, ks[1:]...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
|
func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubContext, env map[string]string) {
|
||||||
env["CI"] = "true"
|
env["CI"] = "true"
|
||||||
env["GITHUB_WORKFLOW"] = github.Workflow
|
env["GITHUB_WORKFLOW"] = github.Workflow
|
||||||
@@ -1655,7 +1614,7 @@ func imageOSFromImage(image string) string {
|
|||||||
func setActionRuntimeVars(rc *RunContext, env map[string]string) {
|
func setActionRuntimeVars(rc *RunContext, env map[string]string) {
|
||||||
actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL")
|
actionsRuntimeURL := os.Getenv("ACTIONS_RUNTIME_URL")
|
||||||
if actionsRuntimeURL == "" {
|
if actionsRuntimeURL == "" {
|
||||||
actionsRuntimeURL = fmt.Sprintf("http://%s:%s/", rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort)
|
actionsRuntimeURL = "http://" + net.JoinHostPort(rc.Config.ArtifactServerAddr, rc.Config.ArtifactServerPort) + "/"
|
||||||
}
|
}
|
||||||
env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL
|
env["ACTIONS_RUNTIME_URL"] = actionsRuntimeURL
|
||||||
|
|
||||||
@@ -1666,41 +1625,17 @@ func setActionRuntimeVars(rc *RunContext, env map[string]string) {
|
|||||||
env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken
|
env["ACTIONS_RUNTIME_TOKEN"] = actionsRuntimeToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) {
|
func registryCredentials(credentials map[string]string, prefix string) (string, string, error) {
|
||||||
container := rc.Run.Job().Container()
|
|
||||||
if container == nil || container.Credentials == nil {
|
|
||||||
return "", "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return rc.interpolateCredentials(ctx, container.Credentials, "container.")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials map[string]string, prefix string) (string, string, error) {
|
|
||||||
if credentials == nil {
|
if credentials == nil {
|
||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
if len(credentials) != 2 {
|
if len(credentials) != 2 {
|
||||||
return "", "", errors.New("invalid property count for key 'credentials:'")
|
return "", "", errors.New("invalid property count for key 'credentials:'")
|
||||||
}
|
}
|
||||||
|
for _, key := range []string{"username", "password"} {
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
if credentials[key] == "" {
|
||||||
interpolate := func(key string) (string, error) {
|
return "", "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
|
||||||
value, err := ee.Interpolate(ctx, credentials[key])
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to interpolate %scredentials.%s: %w", prefix, key, err)
|
|
||||||
}
|
}
|
||||||
if value == "" {
|
|
||||||
return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
username, err := interpolate("username")
|
return credentials["username"], credentials["password"], nil
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
password, err := interpolate("password")
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
return username, password, nil
|
|
||||||
}
|
}
|
||||||
+381
-142
@@ -7,13 +7,21 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -22,6 +30,7 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
|
"github.com/moby/moby/api/types/volume"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
@@ -176,38 +185,6 @@ func TestRunContext_EvalBool(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextHandleCredentialsDoesNotUseDockerSecrets(t *testing.T) {
|
|
||||||
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
|
||||||
name: test
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
job:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps: []
|
|
||||||
`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rc := &RunContext{
|
|
||||||
Config: &Config{
|
|
||||||
Secrets: map[string]string{
|
|
||||||
"DOCKER_USERNAME": "docker-user",
|
|
||||||
"DOCKER_PASSWORD": "docker-password",
|
|
||||||
},
|
|
||||||
Env: map[string]string{},
|
|
||||||
},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "job",
|
|
||||||
Workflow: workflow,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit job container pull credentials.
|
|
||||||
username, password, err := rc.handleCredentials(t.Context())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, username)
|
|
||||||
assert.Empty(t, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeContainer turns every container operation into a no-op, so startJobContainer
|
// fakeContainer turns every container operation into a no-op, so startJobContainer
|
||||||
// runs without a Docker daemon. The embedded interface is nil, so any method the
|
// runs without a Docker daemon. The embedded interface is nil, so any method the
|
||||||
// test does not exercise panics rather than silently doing the wrong thing.
|
// test does not exercise panics rather than silently doing the wrong thing.
|
||||||
@@ -221,8 +198,9 @@ func (fakeContainer) Start(bool) common.Executor { return func(context.Context)
|
|||||||
|
|
||||||
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
|
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
|
||||||
|
|
||||||
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
|
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
|
||||||
func (fakeContainer) GetActPath() string { return "/var/run/act" }
|
func (fakeContainer) GetActPath() string { return "/var/run/act" }
|
||||||
|
func (fakeContainer) ToContainerPath(path string) string { return path }
|
||||||
func (fakeContainer) Create([]string, []string) common.Executor {
|
func (fakeContainer) Create([]string, []string) common.Executor {
|
||||||
return func(context.Context) error { return nil }
|
return func(context.Context) error { return nil }
|
||||||
}
|
}
|
||||||
@@ -237,8 +215,20 @@ func (fakeContainer) Inspect(context.Context) (*container.Info, error) {
|
|||||||
|
|
||||||
func (fakeContainer) DumpLogs(context.Context) error { return nil }
|
func (fakeContainer) DumpLogs(context.Context) error { return nil }
|
||||||
|
|
||||||
// startJobContainerInputs runs startJobContainer against fakeContainer and returns the
|
func fakeDockerDaemon(t *testing.T, handler http.HandlerFunc) {
|
||||||
// inputs it built, one per container.
|
t.Helper()
|
||||||
|
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
if strings.HasSuffix(request.URL.Path, "/_ping") {
|
||||||
|
writer.Header().Set("API-Version", "1.47")
|
||||||
|
} else {
|
||||||
|
handler(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(daemon.Close)
|
||||||
|
t.Setenv("DOCKER_HOST", daemon.URL)
|
||||||
|
}
|
||||||
|
|
||||||
func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*container.NewContainerInput {
|
func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*container.NewContainerInput {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
workflow, err := model.ReadWorkflow(strings.NewReader(workflowYAML))
|
workflow, err := model.ReadWorkflow(strings.NewReader(workflowYAML))
|
||||||
@@ -269,9 +259,28 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
|
|||||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||||
|
|
||||||
// the inputs are built before the missing daemon fails the first call
|
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
require.Error(t, rc.startJobContainer()(t.Context()))
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/_ping"):
|
||||||
|
w.Header().Set("API-Version", "1.47")
|
||||||
|
_, _ = io.WriteString(w, "OK")
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/containers/json"), strings.HasSuffix(r.URL.Path, "/networks"):
|
||||||
|
_, _ = io.WriteString(w, "[]")
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/volumes"):
|
||||||
|
_, _ = io.WriteString(w, `{"Volumes":[]}`)
|
||||||
|
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/volumes/"):
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/info"):
|
||||||
|
_, _ = io.WriteString(w, `{"Architecture":"amd64","OSType":"linux"}`)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(daemon.Close)
|
||||||
|
t.Setenv("DOCKER_HOST", daemon.URL)
|
||||||
|
require.NoError(t, rc.startJobContainer()(t.Context()))
|
||||||
|
|
||||||
return inputs
|
return inputs
|
||||||
}
|
}
|
||||||
@@ -329,6 +338,28 @@ jobs:
|
|||||||
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
|
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
|
||||||
require.Empty(t, redis.Binds) // the docker socket is the job container's alone
|
require.Empty(t, redis.Binds) // the docker socket is the job container's alone
|
||||||
require.Empty(t, redis.WorkingDir)
|
require.Empty(t, redis.WorkingDir)
|
||||||
|
require.Nil(t, redis.Entrypoint)
|
||||||
|
require.Nil(t, redis.Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartJobContainerEvaluatesContainersOnce(t *testing.T) {
|
||||||
|
inputs := startJobContainerInputs(t, `
|
||||||
|
jobs:
|
||||||
|
job:
|
||||||
|
container: ${{ fromJSON('{"image":"node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }}
|
||||||
|
services: ${{ fromJSON('{"redis":{"image":"redis:latest","volumes":["data:/data"],"env":{"TITLE":"${{ github.job }}"},"credentials":{"username":"user","password":"${{ github.job }}"},"entrypoint":"/entry.sh","command":"redis-server --port 6380"}}') }}
|
||||||
|
`, &Config{})
|
||||||
|
redis, job := inputs[0], inputs[1]
|
||||||
|
|
||||||
|
require.Equal(t, "redis:latest", redis.Image)
|
||||||
|
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
|
||||||
|
require.Equal(t, []string{"TITLE=${{ github.job }}"}, redis.Env)
|
||||||
|
require.Equal(t, "${{ github.job }}", redis.Password)
|
||||||
|
require.Equal(t, []string{"/entry.sh"}, redis.Entrypoint)
|
||||||
|
require.Equal(t, []string{"redis-server", "--port", "6380"}, redis.Cmd)
|
||||||
|
require.Equal(t, "node:20", job.Image)
|
||||||
|
require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions)
|
||||||
|
require.Equal(t, "${{ vars.TITLE }}", job.Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the workflow's options may be stripped later, so the two sources have to reach the
|
// Only the workflow's options may be stripped later, so the two sources have to reach the
|
||||||
@@ -452,8 +483,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
config := testcase.rc.Config
|
config := testcase.rc.Config
|
||||||
config.Workdir = testcase.name
|
config.Workdir = testcase.name
|
||||||
config.BindWorkdir = bindWorkDir
|
config.BindWorkdir = bindWorkDir
|
||||||
gotbind, gotmount, err := rctemplate.GetBindsAndMounts()
|
gotbind, gotmount := rctemplate.GetBindsAndMounts()
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Name binds/mounts are either/or
|
// Name binds/mounts are either/or
|
||||||
if config.BindWorkdir {
|
if config.BindWorkdir {
|
||||||
@@ -489,8 +519,9 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
|
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
|
||||||
job := &model.Job{}
|
job := &model.Job{}
|
||||||
err := job.RawContainer.Encode(map[string][]string{
|
err := job.RawContainer.Encode(map[string]any{
|
||||||
"volumes": {"${{ secrets.MAME }}:/root/.mame/roms:ro"},
|
"image": "node:20",
|
||||||
|
"volumes": []string{"${{ secrets.MAME }}:/root/.mame/roms:ro"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -511,9 +542,9 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||||
|
require.NoError(t, rc.resolvePlatformImage(context.Background()))
|
||||||
|
|
||||||
gotbind, gotmount, err := rc.GetBindsAndMounts()
|
gotbind, gotmount := rc.GetBindsAndMounts()
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||||
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
||||||
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
||||||
@@ -521,12 +552,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
|
|
||||||
for _, testcase := range tests {
|
for _, testcase := range tests {
|
||||||
t.Run(testcase.name, func(t *testing.T) {
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
job := &model.Job{}
|
|
||||||
err := job.RawContainer.Encode(map[string][]string{
|
|
||||||
"volumes": testcase.volumes,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Name: "TestRCName",
|
Name: "TestRCName",
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
@@ -538,12 +563,11 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
BindWorkdir: false,
|
BindWorkdir: false,
|
||||||
SharedToolCache: true, // so OverridesToolCache has a mount to displace
|
SharedToolCache: true, // so OverridesToolCache has a mount to displace
|
||||||
},
|
},
|
||||||
|
containerSpec: model.ContainerSpec{Volumes: testcase.volumes},
|
||||||
}
|
}
|
||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
|
||||||
|
|
||||||
gotbind, gotmount, err := rc.GetBindsAndMounts()
|
gotbind, gotmount := rc.GetBindsAndMounts()
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
if len(testcase.wantbind) > 0 {
|
if len(testcase.wantbind) > 0 {
|
||||||
assert.Contains(t, gotbind, testcase.wantbind)
|
assert.Contains(t, gotbind, testcase.wantbind)
|
||||||
@@ -578,15 +602,34 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, gotmount, err := rc.GetBindsAndMounts()
|
_, gotmount := rc.GetBindsAndMounts()
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||||
|
|
||||||
rc.Config.SharedToolCache = true
|
rc.Config.SharedToolCache = true
|
||||||
_, gotmount, err = rc.GetBindsAndMounts()
|
_, gotmount = rc.GetBindsAndMounts()
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("DaemonMountsAboveOwnerRepo", func(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("container paths are linux paths")
|
||||||
|
}
|
||||||
|
rc := &RunContext{
|
||||||
|
Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}},
|
||||||
|
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
gotbind, _ := rc.GetBindsAndMounts()
|
||||||
|
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
|
||||||
|
|
||||||
|
rc.Config.BindWorkdir = false
|
||||||
|
_, gotmount := rc.GetBindsAndMounts()
|
||||||
|
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
|
||||||
|
|
||||||
|
rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
|
||||||
|
_, gotmount = rc.GetBindsAndMounts()
|
||||||
|
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextValidVolumes(t *testing.T) {
|
func TestRunContextValidVolumes(t *testing.T) {
|
||||||
@@ -615,6 +658,8 @@ func TestRunContextValidVolumes(t *testing.T) {
|
|||||||
assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir)
|
assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||||
rc.Config.BindWorkdir = true
|
rc.Config.BindWorkdir = true
|
||||||
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
|
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||||
|
rc.Config.PresetGitHubContext = &model.GithubContext{}
|
||||||
|
assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||||
@@ -627,55 +672,182 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
|||||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := rc.cleanupJobResources("external-network", false)(context.Background())
|
err := rc.cleanupJobResources("external-network", false, true)(common.WithDryrun(t.Context(), true))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
service.AssertExpectations(t)
|
service.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup used to bail out on a previous step's error and on a cancelled context
|
func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) {
|
||||||
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
now := time.Date(2026, time.September, 10, 12, 0, 0, 0, time.UTC)
|
||||||
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
|
||||||
|
|
||||||
jobContainer := &containerMock{}
|
|
||||||
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
|
|
||||||
service := &containerMock{}
|
|
||||||
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
|
|
||||||
service.On("Close").Return(func(context.Context) error { return nil }).Once()
|
|
||||||
|
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Name: "job",
|
Config: &Config{ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{RunnerUUID: "runner-1"}},
|
||||||
Config: &Config{},
|
Run: &model.Run{Workflow: &model.Workflow{Name: "workflow"}, JobID: "job"},
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
JobContainer: fakeContainer{},
|
||||||
JobContainer: jobContainer,
|
|
||||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
|
||||||
}
|
}
|
||||||
|
volumes := map[string]volume.Volume{}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
cancel()
|
path := strings.TrimPrefix(request.URL.Path, "/v1.47")
|
||||||
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx))
|
switch {
|
||||||
jobContainer.AssertExpectations(t)
|
case path == "/volumes/create":
|
||||||
service.AssertExpectations(t)
|
options := volume.Volume{CreatedAt: now.Format(time.RFC3339)}
|
||||||
|
assert.NoError(t, json.UnmarshalRead(request.Body, &options))
|
||||||
|
volumes[options.Name] = options
|
||||||
|
assert.NoError(t, json.MarshalWrite(writer, volumes[options.Name]))
|
||||||
|
case path == "/volumes":
|
||||||
|
assert.JSONEq(t, `{"label":{"com.gitea.runner.uuid=runner-1":true},"dangling":{"true":true}}`, request.URL.Query().Get("filters"))
|
||||||
|
assert.NoError(t, json.MarshalWrite(writer, map[string]any{"Volumes": slices.Collect(maps.Values(volumes))}))
|
||||||
|
case request.Method == http.MethodDelete:
|
||||||
|
name := strings.TrimPrefix(path, "/volumes/")
|
||||||
|
assert.NotContains(t, []string{"1", "true"}, request.URL.Query().Get("force"))
|
||||||
|
if name == "became-active" || name == "remove-failed" {
|
||||||
|
writer.WriteHeader(http.StatusConflict)
|
||||||
|
_, _ = fmt.Fprintf(writer, `{"message":%q}`, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(volumes, name)
|
||||||
|
writer.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected Docker request: %s %s", request.Method, request.URL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
name := rc.jobContainerName()
|
||||||
|
require.NoError(t, rc.createJobVolumes(t.Context(), map[string]string{name: "/workspace", name + "-env": "/var/run/act", "shared-cache": "/cache"}))
|
||||||
|
assert.ElementsMatch(t, []string{name, name + "-env"}, slices.Collect(maps.Keys(volumes)))
|
||||||
|
rc.deferVolumeCleanup = func(common.Executor) {}
|
||||||
|
require.NoError(t, rc.cleanupJobResources("", false, false)(t.Context()))
|
||||||
|
require.Len(t, volumes, 2)
|
||||||
|
for _, name := range []string{"became-active", "remove-failed"} {
|
||||||
|
volumes[name] = volume.Volume{Name: name, Labels: volumes[rc.jobContainerName()].Labels, CreatedAt: now.Format(time.RFC3339)}
|
||||||
|
}
|
||||||
|
volumes["foreign"] = volume.Volume{Name: "foreign", Labels: map[string]string{"com.gitea.runner.uuid": "runner-2"}, CreatedAt: now.Format(time.RFC3339)}
|
||||||
|
volumes["fresh"] = volume.Volume{Name: "fresh", Labels: volumes[name].Labels, CreatedAt: now.Add(48 * time.Hour).Format(time.RFC3339)}
|
||||||
|
volumes["unknown-age"] = volume.Volume{Name: "unknown-age", Labels: volumes[name].Labels}
|
||||||
|
err := container.RemoveOrphanJobVolumes(t.Context(), "runner-1", now.Add(24*time.Hour))
|
||||||
|
require.ErrorContains(t, err, "became-active")
|
||||||
|
require.ErrorContains(t, err, "remove-failed")
|
||||||
|
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||||
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
t.Setenv("TMPDIR", "/tmp")
|
||||||
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
proxyDir := t.TempDir()
|
||||||
func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
|
for _, name := range []string{"synchronous", "proxy", "closed proxy", "deferred", "preclean deferred", "canceled"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
proxy, preclean, deferred := strings.HasSuffix(name, "proxy"), strings.HasPrefix(name, "preclean"), strings.HasSuffix(name, "deferred")
|
||||||
|
if proxy && runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Unix socket ownership is unavailable on Windows")
|
||||||
|
}
|
||||||
|
jobError, removeError, closeError := errors.New("remove job"), errors.New("remove service"), errors.New("close service")
|
||||||
|
job, service := &containerMock{}, &containerMock{}
|
||||||
|
job.On("Remove").Return(func(context.Context) error { return jobError }).Once()
|
||||||
|
service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
|
||||||
|
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{CacheContainer: "cache-container"},
|
||||||
|
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||||
|
JobContainer: job,
|
||||||
|
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||||
|
}
|
||||||
|
var volumeCleanup []common.Executor
|
||||||
|
if deferred {
|
||||||
|
rc.deferVolumeCleanup = func(cleanup common.Executor) { volumeCleanup = append(volumeCleanup, cleanup) }
|
||||||
|
}
|
||||||
|
volumeRemovals := 0
|
||||||
|
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method == http.MethodDelete {
|
||||||
|
volumeRemovals++
|
||||||
|
}
|
||||||
|
operation := request.Method + " " + strings.TrimPrefix(request.URL.Path, "/v1.47")
|
||||||
|
if request.URL.Query().Has("filters") {
|
||||||
|
operation = "labelled " + operation
|
||||||
|
}
|
||||||
|
writer.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = fmt.Fprintf(writer, `{"message":%q}`, operation)
|
||||||
|
})
|
||||||
|
if proxy {
|
||||||
|
listener, err := net.Listen("unix", filepath.Join(proxyDir, "d.sock"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, listener.Close()) })
|
||||||
|
rc.dockerProxy, err = container.StartDockerProxy(listener.Addr().String(), proxyDir, rc.jobContainerName())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, rc.closeDockerProxy(context.Background())) })
|
||||||
|
if name == "closed proxy" {
|
||||||
|
require.NoError(t, rc.closeDockerProxy(t.Context()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
if name == "canceled" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
err := rc.cleanupJobResources("job-network", true, preclean)(ctx)
|
||||||
|
if deferred && !preclean {
|
||||||
|
require.Len(t, volumeCleanup, 1)
|
||||||
|
assert.Zero(t, volumeRemovals)
|
||||||
|
err = errors.Join(err, volumeCleanup[0](ctx))
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, volumeCleanup)
|
||||||
|
}
|
||||||
|
for _, failure := range []error{jobError, removeError, closeError} {
|
||||||
|
require.ErrorIs(t, err, failure)
|
||||||
|
}
|
||||||
|
if name == "canceled" {
|
||||||
|
require.ErrorIs(t, err, context.Canceled)
|
||||||
|
} else {
|
||||||
|
for _, operation := range []string{"DELETE /volumes/" + rc.jobContainerName(), "DELETE /volumes/" + rc.jobContainerName() + "-env", "POST /networks/job-network/disconnect", "GET /networks"} {
|
||||||
|
require.ErrorContains(t, err, operation)
|
||||||
|
}
|
||||||
|
assert.Equal(t, 2, volumeRemovals)
|
||||||
|
}
|
||||||
|
assert.Equal(t, proxy || preclean, strings.Contains(err.Error(), "labelled GET /containers/json"))
|
||||||
|
if proxy || preclean {
|
||||||
|
require.ErrorContains(t, err, "labelled GET /networks")
|
||||||
|
require.ErrorContains(t, err, "labelled GET /volumes")
|
||||||
|
}
|
||||||
|
assert.Nil(t, rc.dockerProxy)
|
||||||
|
assert.Equal(t, proxy, rc.hadDockerProxy)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterpolateOutputsIsPerMatrixComboKeepingNeedsOutputs(t *testing.T) {
|
||||||
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}}
|
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}}
|
||||||
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
|
require.NoError(t, job.RawOutputs.Encode(job.Outputs))
|
||||||
|
needed := &model.Job{Outputs: map[string]string{"o": "from-gitea"}}
|
||||||
|
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job, "needed": needed}}}
|
||||||
r := &runnerImpl{config: &Config{}}
|
r := &runnerImpl{config: &Config{}}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := r.newRunContext(ctx, &model.Run{JobID: "needed", Workflow: run.Workflow}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"})
|
rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"})
|
rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
rcEmpty, err := r.newRunContext(ctx, run, map[string]any{"v": ""})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, job.Outputs)
|
||||||
|
|
||||||
require.NoError(t, rcA.interpolateOutputs()(ctx))
|
require.NoError(t, rcA.interpolateOutputs()(ctx))
|
||||||
require.NoError(t, rcB.interpolateOutputs()(ctx))
|
require.NoError(t, rcB.interpolateOutputs()(ctx))
|
||||||
|
require.NoError(t, rcEmpty.interpolateOutputs()(ctx))
|
||||||
|
|
||||||
// Last combo wins (matching GitHub) instead of being frozen to combo A's "a".
|
require.Equal(t, map[string]string{"o": "b"}, job.Outputs)
|
||||||
require.Equal(t, "b", job.Outputs["o"])
|
require.Equal(t, map[string]string{"o": "from-gitea"}, needed.Outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A whole-value `outputs:` expression only reaches the typed field through DecodeRaw.
|
||||||
|
func TestInterpolateOutputsFromExpression(t *testing.T) {
|
||||||
|
var rawOutputs yaml.Node
|
||||||
|
require.NoError(t, rawOutputs.Encode(`${{ fromJSON('{"o":"resolved"}') }}`))
|
||||||
|
|
||||||
|
job := &model.Job{RawOutputs: rawOutputs}
|
||||||
|
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
|
||||||
|
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), run, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, rc.interpolateOutputs()(t.Context()))
|
||||||
|
require.Equal(t, "resolved", job.Outputs["o"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGitHubContext(t *testing.T) {
|
func TestGetGitHubContext(t *testing.T) {
|
||||||
@@ -734,6 +906,12 @@ func TestGetGitHubContext(t *testing.T) {
|
|||||||
assert.Equal(t, ghc.RunnerPerflog, "/dev/null") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Equal(t, ghc.RunnerPerflog, "/dev/null") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, ghc.Token, rc.Config.Secrets["GITHUB_TOKEN"])
|
assert.Equal(t, ghc.Token, rc.Config.Secrets["GITHUB_TOKEN"])
|
||||||
assert.Equal(t, ghc.Job, "job1") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Equal(t, ghc.Job, "job1") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
rc.Config.PresetGitHubContext = &model.GithubContext{Actor: "preset-actor", TriggeringActor: "triggerer", Job: "preset-job"}
|
||||||
|
ghc = rc.getGithubContext(context.Background())
|
||||||
|
assert.Equal(t, "preset-actor", ghc.Actor)
|
||||||
|
assert.Equal(t, "triggerer", ghc.TriggeringActor)
|
||||||
|
assert.Equal(t, "job1", ghc.Job)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGithubContextRef(t *testing.T) {
|
func TestGetGithubContextRef(t *testing.T) {
|
||||||
@@ -963,6 +1141,13 @@ if: always()`, ""),
|
|||||||
if: false`, ""),
|
if: false`, ""),
|
||||||
})
|
})
|
||||||
assertObject.False(rc.isEnabled(context.Background()))
|
assertObject.False(rc.isEnabled(context.Background()))
|
||||||
|
|
||||||
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
|
"job1": createJob(t, `if: ${{ fromJSON('not-json') }}`, ""),
|
||||||
|
})
|
||||||
|
enabled, err := rc.isEnabled(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertObject.False(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextGetEnv(t *testing.T) {
|
func TestRunContextGetEnv(t *testing.T) {
|
||||||
@@ -1064,57 +1249,96 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
|||||||
type ctxKey string
|
type ctxKey string
|
||||||
const sentinel = ctxKey("sentinel")
|
const sentinel = ctxKey("sentinel")
|
||||||
|
|
||||||
// the fresh context is cancelled via defer on return, so capture state inside the stub
|
for name, canceled := range map[string]bool{"cancellation during cleanup": false, "already canceled": true} {
|
||||||
type capture struct {
|
t.Run(name, func(t *testing.T) {
|
||||||
calls int
|
ctx, cancel := context.WithCancel(context.WithValue(t.Context(), sentinel, "v"))
|
||||||
err error
|
defer cancel()
|
||||||
sentinel any
|
if canceled {
|
||||||
}
|
cancel()
|
||||||
newRC := func(c *capture) *RunContext {
|
}
|
||||||
return &RunContext{
|
calls := 0
|
||||||
JobName: "job",
|
(&RunContext{cleanUpJobContainer: func(ctx context.Context) error {
|
||||||
cleanUpJobContainer: func(ctx context.Context) error {
|
calls++
|
||||||
c.calls++
|
cancel()
|
||||||
c.err = ctx.Err()
|
deadline, ok := ctx.Deadline()
|
||||||
c.sentinel = ctx.Value(sentinel)
|
require.True(t, ok)
|
||||||
|
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, time.Second)
|
||||||
|
require.NoError(t, ctx.Err())
|
||||||
|
assert.Equal(t, "v", ctx.Value(sentinel))
|
||||||
return nil
|
return nil
|
||||||
},
|
}}).cleanupFailedStart(ctx)
|
||||||
}
|
assert.Equal(t, 1, calls)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("runs teardown on the live context", func(t *testing.T) {
|
for _, testcase := range []struct {
|
||||||
var c capture
|
name, health string
|
||||||
ctx := context.WithValue(context.Background(), sentinel, "v")
|
pullError error
|
||||||
|
}{
|
||||||
|
{name: "healthy", health: container.HealthHealthy},
|
||||||
|
{name: "unhealthy", health: container.HealthUnhealthy},
|
||||||
|
{name: "pull failure", pullError: errors.New("pull failed")},
|
||||||
|
} {
|
||||||
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
|
var operations []string
|
||||||
|
record := func(operation string, failure error) func(context.Context) error {
|
||||||
|
return func(context.Context) error {
|
||||||
|
operations = append(operations, operation)
|
||||||
|
return failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job, service := &containerMock{}, &containerMock{}
|
||||||
|
for name, instance := range map[string]*containerMock{"job": job, "service": service} {
|
||||||
|
instance.On("Pull", true).Return(record(name+".Pull", map[string]error{"job": testcase.pullError}[name]))
|
||||||
|
instance.On("Create", mock.Anything, mock.Anything).Return(record(name+".Create", nil))
|
||||||
|
instance.On("Start", false).Return(record(name+".Start", nil))
|
||||||
|
instance.On("Remove").Return(record(name+".Remove", nil))
|
||||||
|
instance.On("Close").Return(record(name+".Close", nil))
|
||||||
|
}
|
||||||
|
job.On("Copy", mock.Anything, mock.Anything).Return(record("job.Copy", nil))
|
||||||
|
job.On("Inspect", mock.Anything).Return(&container.Info{ID: "job-id"}, nil).
|
||||||
|
Run(func(mock.Arguments) { operations = append(operations, "job.Inspect") })
|
||||||
|
for _, health := range []string{container.HealthStarting, testcase.health} {
|
||||||
|
service.On("Inspect", mock.Anything).Return(&container.Info{State: "running", Health: health}, nil).
|
||||||
|
Run(func(mock.Arguments) { operations = append(operations, "service.Inspect") }).Once()
|
||||||
|
}
|
||||||
|
service.On("DumpLogs", mock.Anything).Return(nil).
|
||||||
|
Run(func(mock.Arguments) { operations = append(operations, "service.DumpLogs") })
|
||||||
|
origNewContainer := newContainer
|
||||||
|
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
|
return map[string]*containerMock{"postgres:latest": service, "node:20": job}[input.Image]
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newContainer = origNewContainer })
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader("jobs: {job: {services: {postgres: {image: postgres:latest}}}}"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{ForcePull: true, ContainerNetworkMode: "host", Workdir: "/workspace"},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: workflow},
|
||||||
|
platformImage: "node:20",
|
||||||
|
}
|
||||||
|
ctx := common.WithDryrun(t.Context(), true)
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx)
|
||||||
|
want := "job.Remove service.Remove service.Close service.Pull job.Pull"
|
||||||
|
if testcase.pullError != nil {
|
||||||
|
require.ErrorIs(t, err, testcase.pullError)
|
||||||
|
} else {
|
||||||
|
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect"
|
||||||
|
if testcase.health == container.HealthUnhealthy {
|
||||||
|
require.ErrorContains(t, err, "the service 'postgres' is unhealthy")
|
||||||
|
want += " service.DumpLogs"
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
newRC(&c).cleanupFailedStart(ctx)
|
(&RunContext{}).cleanupFailedStart(t.Context())
|
||||||
|
|
||||||
assert.Equal(t, 1, c.calls)
|
|
||||||
require.NoError(t, c.err)
|
|
||||||
assert.Equal(t, "v", c.sentinel)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back to a fresh context when the input is done", func(t *testing.T) {
|
|
||||||
var c capture
|
|
||||||
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
newRC(&c).cleanupFailedStart(ctx)
|
|
||||||
|
|
||||||
assert.Equal(t, 1, c.calls)
|
|
||||||
require.NoError(t, c.err)
|
|
||||||
assert.Nil(t, c.sentinel)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
|
|
||||||
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWaitForServiceContainers(t *testing.T) {
|
func TestWaitForServiceContainers(t *testing.T) {
|
||||||
origInterval := serviceReadyPollInterval
|
|
||||||
serviceReadyPollInterval = time.Millisecond
|
|
||||||
defer func() { serviceReadyPollInterval = origInterval }()
|
|
||||||
|
|
||||||
newRunContext := func(timeout time.Duration, services ...*serviceContainer) *RunContext {
|
newRunContext := func(timeout time.Duration, services ...*serviceContainer) *RunContext {
|
||||||
return &RunContext{
|
return &RunContext{
|
||||||
Config: &Config{ServiceReadyTimeout: timeout},
|
Config: &Config{ServiceReadyTimeout: timeout},
|
||||||
@@ -1132,16 +1356,26 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
service.AssertExpectations(t)
|
service.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("waits while a service is still starting", func(t *testing.T) {
|
t.Run("polls at a fixed interval and logs starting only once", func(t *testing.T) {
|
||||||
service := &containerMock{}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
service.On("Inspect", mock.Anything).
|
var pollTimes []time.Duration
|
||||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Twice()
|
started := time.Now()
|
||||||
service.On("Inspect", mock.Anything).
|
service := &containerMock{}
|
||||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
|
service.On("Inspect", mock.Anything).
|
||||||
|
Run(func(_ mock.Arguments) { pollTimes = append(pollTimes, time.Since(started)) }).
|
||||||
|
Return(&container.Info{ID: "id", State: "running", Health: container.HealthStarting}, nil).Twice()
|
||||||
|
service.On("Inspect", mock.Anything).
|
||||||
|
Run(func(_ mock.Arguments) { pollTimes = append(pollTimes, time.Since(started)) }).
|
||||||
|
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
|
||||||
|
|
||||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
var output bytes.Buffer
|
||||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
logger := log.New()
|
||||||
service.AssertExpectations(t)
|
logger.SetOutput(&output)
|
||||||
|
require.NoError(t, newRunContext(0, &serviceContainer{name: "postgres", container: service}).waitForServiceContainers()(common.WithLogger(t.Context(), logger.WithFields(nil))))
|
||||||
|
assert.Equal(t, []time.Duration{0, time.Second, 2 * time.Second}, pollTimes)
|
||||||
|
assert.Equal(t, 1, strings.Count(output.String(), "postgres service is starting."))
|
||||||
|
assert.Contains(t, output.String(), "postgres service is healthy.")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("fails with the probe output when a service is unhealthy", func(t *testing.T) {
|
t.Run("fails with the probe output when a service is unhealthy", func(t *testing.T) {
|
||||||
@@ -1298,7 +1532,7 @@ func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
|
|||||||
job := &containerMock{}
|
job := &containerMock{}
|
||||||
job.On("Inspect", mock.Anything).Return(&container.Info{
|
job.On("Inspect", mock.Anything).Return(&container.Info{
|
||||||
ID: "job-container-id",
|
ID: "job-container-id",
|
||||||
Mounts: map[string]string{"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data"},
|
Mounts: map[string]string{"/workspace": "/var/lib/docker/volumes/job/_data"},
|
||||||
}, nil)
|
}, nil)
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{Workdir: "/workspace/owner/repo/"},
|
Config: &Config{Workdir: "/workspace/owner/repo/"},
|
||||||
@@ -1309,7 +1543,7 @@ func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
|
|||||||
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
|
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
|
||||||
|
|
||||||
assert.Equal(t, "job-container-id", rc.jobContainerID)
|
assert.Equal(t, "job-container-id", rc.jobContainerID)
|
||||||
assert.Equal(t, "/var/lib/docker/volumes/job/_data", rc.Env["GITEA_DOCKER_WORKSPACE"])
|
assert.Equal(t, "/var/lib/docker/volumes/job/_data/owner/repo", rc.Env["GITEA_DOCKER_WORKSPACE"])
|
||||||
|
|
||||||
rc.Config.Workdir = "/elsewhere"
|
rc.Config.Workdir = "/elsewhere"
|
||||||
rc.Env = map[string]string{}
|
rc.Env = map[string]string{}
|
||||||
@@ -1472,6 +1706,10 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
|||||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||||
rc.Config.RunnerName = "runner-1"
|
rc.Config.RunnerName = "runner-1"
|
||||||
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
|
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
|
||||||
|
t.Setenv("ACTIONS_RUNTIME_URL", "")
|
||||||
|
rc.Config.ArtifactServerPath = "artifacts"
|
||||||
|
rc.Config.ArtifactServerAddr = "2001:db8::1"
|
||||||
|
rc.Config.ArtifactServerPort = "8080"
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env)
|
rc.withGithubEnv(ctx, &model.GithubContext{Workspace: "/workspace/owner/repo"}, env)
|
||||||
@@ -1480,4 +1718,5 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
|||||||
assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"])
|
assert.Equal(t, "self-hosted", env["RUNNER_ENVIRONMENT"])
|
||||||
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
|
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
|
||||||
assert.Equal(t, "1", env["RUNNER_DEBUG"])
|
assert.Equal(t, "1", env["RUNNER_DEBUG"])
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080/", env["ACTIONS_RUNTIME_URL"])
|
||||||
}
|
}
|
||||||
+55
-32
@@ -7,7 +7,6 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -15,10 +14,12 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/act/ghcontext"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config contains the config for a new runner
|
// Config contains the config for a new runner
|
||||||
@@ -56,6 +57,7 @@ type Config struct {
|
|||||||
DisableActEnv bool // do not inject the ACT=true environment variable into jobs
|
DisableActEnv bool // do not inject the ACT=true environment variable into jobs
|
||||||
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
|
||||||
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
||||||
|
CacheContainer string // let jobs reach an otherwise isolated cache
|
||||||
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds
|
||||||
NoActionPatch bool // run actions exactly as published, applying no compatibility patches, see patch_actions.go
|
NoActionPatch bool // run actions exactly as published, applying no compatibility patches, see patch_actions.go
|
||||||
|
|
||||||
@@ -155,6 +157,18 @@ func (runner *runnerImpl) configure() (*runnerImpl, error) {
|
|||||||
return runner, nil
|
return runner, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maxParallelFor(strategy *model.Strategy, combinations int) int {
|
||||||
|
maxParallel := 4 // actionslib has no default for an undeclared max-parallel
|
||||||
|
if strategy != nil {
|
||||||
|
if limit, err := strategy.MaxParallel(); err != nil {
|
||||||
|
log.Errorf("Ignoring invalid max-parallel: %v", err)
|
||||||
|
} else if limit > 0 {
|
||||||
|
maxParallel = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return min(maxParallel, combinations)
|
||||||
|
}
|
||||||
|
|
||||||
// NewPlanExecutor ...
|
// NewPlanExecutor ...
|
||||||
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||||
maxJobNameLen := 0
|
maxJobNameLen := 0
|
||||||
@@ -191,21 +205,29 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
log.Debugf("Job.With: %v", job.With)
|
log.Debugf("Job.With: %v", job.With)
|
||||||
log.Debugf("Job.Result: %v", job.Result)
|
log.Debugf("Job.Result: %v", job.Result)
|
||||||
|
|
||||||
if job.Strategy != nil {
|
if job.Strategy != nil || job.RawStrategy.Kind == yaml.ScalarNode {
|
||||||
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.FailFast)
|
|
||||||
log.Debugf("Job.Strategy.MaxParallel: %v", job.Strategy.MaxParallel)
|
|
||||||
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
|
||||||
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
|
||||||
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
|
||||||
|
|
||||||
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Resolve template expressions in the matrix node before Matrix() is called.
|
if job.RawStrategy.Kind == yaml.ScalarNode {
|
||||||
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
|
if err := model.DecodeEvaluated("job strategy", job.RawStrategy, strategyRc.ExprEval.shared(ctx).EvaluateYamlNode, &job.Strategy); err != nil {
|
||||||
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
return err
|
||||||
log.Errorf("Error while evaluating matrix: %v", err)
|
}
|
||||||
|
} else {
|
||||||
|
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.GetFailFast())
|
||||||
|
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
||||||
|
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||||
|
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||||
|
// An unevaluated expression is left in place, which GetMatrixes below rejects.
|
||||||
|
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
||||||
|
log.Errorf("Error while evaluating matrix: %v", err)
|
||||||
|
}
|
||||||
|
for _, value := range []*string{&job.Strategy.FailFastString, &job.Strategy.MaxParallelString} {
|
||||||
|
if evaluated, err := strategyRc.ExprEval.Interpolate(ctx, *value); err == nil {
|
||||||
|
*value = evaluated
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,25 +237,21 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
}
|
}
|
||||||
log.Debugf("Job Matrices: %v", matrixes)
|
log.Debugf("Job Matrices: %v", matrixes)
|
||||||
|
|
||||||
maxParallel := 4
|
maxParallel := maxParallelFor(job.Strategy, len(matrixes))
|
||||||
if job.Strategy != nil {
|
|
||||||
// Ensure GetMaxParallel() is called if MaxParallel is still 0
|
|
||||||
if job.Strategy.MaxParallel == 0 {
|
|
||||||
job.Strategy.MaxParallel = job.Strategy.GetMaxParallel()
|
|
||||||
}
|
|
||||||
maxParallel = job.Strategy.MaxParallel
|
|
||||||
log.Debugf("Using job.Strategy.MaxParallel: %d", maxParallel)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(matrixes) < maxParallel {
|
|
||||||
log.Debugf("Adjusting maxParallel from %d to %d (number of matrix combinations)", maxParallel, len(matrixes))
|
|
||||||
maxParallel = len(matrixes)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
||||||
|
|
||||||
for i, matrix := range matrixes {
|
for i, matrix := range matrixes {
|
||||||
rc, err := runner.newRunContext(ctx, run, matrix)
|
index, total := i, len(matrixes)
|
||||||
|
if runner.caller == nil && runner.config.PresetGitHubContext != nil && len(matrix) > 0 {
|
||||||
|
var expanded struct {
|
||||||
|
Index int `yaml:"job-index"`
|
||||||
|
Total int `yaml:"job-total"`
|
||||||
|
}
|
||||||
|
_ = job.RawStrategy.Decode(&expanded) // Gitea expanded the combination, a version writing neither leaves them unknown
|
||||||
|
index, total = expanded.Index, expanded.Total
|
||||||
|
}
|
||||||
|
rc, err := runner.newCombinationRunContext(ctx, run, matrix, index, total)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -305,7 +323,8 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
|
executor := common.NewPipelineExecutor(stagePipeline...).Then(handleFailure(plan))
|
||||||
|
return func(ctx context.Context) error { return executor(ghcontext.WithPinnedCheckout(ctx)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleFailure(plan *model.Plan) common.Executor {
|
func handleFailure(plan *model.Plan) common.Executor {
|
||||||
@@ -322,6 +341,10 @@ func handleFailure(plan *model.Plan) common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) {
|
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) {
|
||||||
|
return runner.newCombinationRunContext(ctx, run, matrix, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (runner *runnerImpl) newCombinationRunContext(ctx context.Context, run *model.Run, matrix map[string]any, index, total int) (*RunContext, error) {
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: runner.config,
|
Config: runner.config,
|
||||||
Run: run,
|
Run: run,
|
||||||
@@ -329,16 +352,16 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
|
|||||||
StepResults: make(map[string]*model.StepResult),
|
StepResults: make(map[string]*model.StepResult),
|
||||||
Matrix: matrix,
|
Matrix: matrix,
|
||||||
caller: runner.caller,
|
caller: runner.caller,
|
||||||
|
jobIndex: index,
|
||||||
|
jobTotal: total,
|
||||||
}
|
}
|
||||||
if err := rc.resolveWorkflowCall(ctx); err != nil {
|
if err := rc.resolveWorkflowCall(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String()))
|
rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String()))
|
||||||
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
|
if job := run.Job(); job != nil && job.RawOutputs.Kind != 0 {
|
||||||
// rewrites the shared Job.Outputs (see interpolateOutputs).
|
job.Outputs = nil // a job no combination ran for reads "" as on GitHub, a Gitea needs placeholder has no RawOutputs and keeps its outputs
|
||||||
if job := run.Job(); job != nil {
|
|
||||||
rc.outputTemplate = maps.Clone(job.Outputs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc, nil
|
return rc, nil
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -29,6 +30,7 @@ var (
|
|||||||
logLevel = log.DebugLevel
|
logLevel = log.DebugLevel
|
||||||
workdir = "testdata"
|
workdir = "testdata"
|
||||||
secrets map[string]string
|
secrets map[string]string
|
||||||
|
testRunID = strings.ToLower(rand.Text()[:8]) // keeps docker names apart from concurrent test processes on one daemon
|
||||||
)
|
)
|
||||||
|
|
||||||
func mapPlatformPicker(platforms map[string]string) func([]string) string {
|
func mapPlatformPicker(platforms map[string]string) func([]string) string {
|
||||||
@@ -216,7 +218,7 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
EventPath: cfg.EventPath,
|
EventPath: cfg.EventPath,
|
||||||
PlatformPicker: mapPlatformPicker(j.platforms),
|
PlatformPicker: mapPlatformPicker(j.platforms),
|
||||||
// fixtures reuse workflow and job names, so parallel tests would collide without this
|
// fixtures reuse workflow and job names, so parallel tests would collide without this
|
||||||
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-"),
|
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-") + "-" + testRunID,
|
||||||
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
||||||
MaxParallel: 2,
|
MaxParallel: 2,
|
||||||
ForceRebuild: true,
|
ForceRebuild: true,
|
||||||
@@ -346,7 +348,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
|
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Secrets: table.secrets,
|
Secrets: table.secrets,
|
||||||
Env: map[string]string{"GITHUB_REPOSITORY": t.Name()},
|
Env: map[string]string{"GITHUB_REPOSITORY": t.Name() + "-" + testRunID},
|
||||||
}
|
}
|
||||||
|
|
||||||
eventFile := filepath.Join(workdir, table.workflowPath, "event.json")
|
eventFile := filepath.Join(workdir, table.workflowPath, "event.json")
|
||||||
@@ -432,6 +434,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
|
|
||||||
tables = append(tables, []TestJobFileInfo{
|
tables = append(tables, []TestJobFileInfo{
|
||||||
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
||||||
|
{workdir, "builtin-checkout", "push", "", platforms, secrets},
|
||||||
{workdir, "inputs-via-env-context", "push", "", platforms, secrets},
|
{workdir, "inputs-via-env-context", "push", "", platforms, secrets},
|
||||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
||||||
|
|||||||
+38
-31
@@ -11,11 +11,11 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/telemetry"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
@@ -66,28 +66,34 @@ func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
|
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) (err error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
stepModel := step.getStepModel()
|
||||||
|
|
||||||
ifExpression := step.getIfExpression(ctx, stage)
|
|
||||||
rc.CurrentStep = stepModel.ID
|
|
||||||
|
|
||||||
stepResult := &model.StepResult{
|
stepResult := &model.StepResult{
|
||||||
Outcome: model.StepStatusSuccess,
|
Outcome: model.StepStatusSuccess,
|
||||||
Conclusion: model.StepStatusSuccess,
|
Conclusion: model.StepStatusSuccess,
|
||||||
Outputs: make(map[string]string),
|
Outputs: make(map[string]string),
|
||||||
}
|
}
|
||||||
|
ctx, endStep := telemetry.StartStep(ctx, stepModel, stage.String())
|
||||||
|
defer func() { endStep(err, stepResult) }()
|
||||||
|
|
||||||
|
ifExpression := step.getIfExpression(ctx, stage)
|
||||||
|
rc.CurrentStep = stepModel.ID
|
||||||
|
|
||||||
if stage == stepStageMain {
|
if stage == stepStageMain {
|
||||||
rc.StepResults[rc.CurrentStep] = stepResult
|
rc.StepResults[rc.CurrentStep] = stepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
err := setupEnv(ctx, step)
|
err = setupEnv(ctx, step)
|
||||||
var runStep bool
|
var runStep bool
|
||||||
if err == nil {
|
if err == nil {
|
||||||
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
|
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
|
||||||
}
|
}
|
||||||
|
if err == nil && runStep {
|
||||||
|
err = setupInputs(ctx, step)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stepResult.Conclusion = model.StepStatusFailure
|
stepResult.Conclusion = model.StepStatusFailure
|
||||||
stepResult.Outcome = model.StepStatusFailure
|
stepResult.Outcome = model.StepStatusFailure
|
||||||
@@ -239,43 +245,39 @@ func setupEnv(ctx context.Context, step step) error {
|
|||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
|
|
||||||
mergeEnv(ctx, step)
|
mergeEnv(ctx, step)
|
||||||
// merge step env last, since it should not be overwritten
|
|
||||||
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
|
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
exprEval := rc.NewExpressionEvaluator(ctx)
|
exprEval := rc.NewExpressionEvaluator(ctx)
|
||||||
for k, v := range *step.getEnv() {
|
stepEnv := step.getStepModel().Environment()
|
||||||
if !strings.HasPrefix(k, "INPUT_") {
|
for k, v := range stepEnv {
|
||||||
if (*step.getEnv())[k], err = exprEval.Interpolate(ctx, v); err != nil {
|
if stepEnv[k], err = exprEval.Interpolate(ctx, v); err != nil {
|
||||||
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// after we have an evaluated step context, update the expressions evaluator with a new env context
|
// merge step env last, since it should not be overwritten
|
||||||
// you can use step level env in the with property of a uses construct
|
mergeIntoMap(step, step.getEnv(), stepEnv)
|
||||||
inputEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) })
|
return nil
|
||||||
for k, v := range *step.getEnv() {
|
}
|
||||||
if strings.HasPrefix(k, "INPUT_") {
|
|
||||||
if (*step.getEnv())[k], err = inputEval().Interpolate(ctx, v); err != nil {
|
// setupInputs evaluates `with` once the condition passed, as GitHub does, so every consumer reads the evaluated With.
|
||||||
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
func setupInputs(ctx context.Context, step step) error {
|
||||||
}
|
stepModel := step.getStepModel()
|
||||||
}
|
if stepModel.RawWith.Kind == 0 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := model.DecodeEvaluated("with", stepModel.RawWith, step.getRunContext().NewStepExpressionEvaluator(ctx, step).shared(ctx).EvaluateYamlNode, &stepModel.With); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mergeIntoMap(step, step.getEnv(), (&model.Step{With: stepModel.With}).GetEnv())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeEnv(ctx context.Context, step step) {
|
func mergeEnv(ctx context.Context, step step) {
|
||||||
env := step.getEnv()
|
env := step.getEnv()
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
job := rc.Run.Job()
|
|
||||||
|
|
||||||
c := job.Container()
|
// container env is the image's baseline, which job env and $GITHUB_ENV override
|
||||||
if c != nil {
|
mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
|
||||||
// container env is the image's baseline, which job env and $GITHUB_ENV override
|
|
||||||
mergeIntoMap(step, env, c.Env, rc.GetEnv())
|
|
||||||
} else {
|
|
||||||
mergeIntoMap(step, env, rc.GetEnv())
|
|
||||||
}
|
|
||||||
|
|
||||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
|
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *env)
|
||||||
}
|
}
|
||||||
@@ -290,7 +292,12 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage)
|
|||||||
defaultStatusCheck = exprparser.DefaultStatusCheckSuccess
|
defaultStatusCheck = exprparser.DefaultStatusCheckSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
runStep, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, defaultStatusCheck)
|
// success() and failure() in a composite's own main steps read the composite's result, other stages the job's
|
||||||
|
jobContext := rc.getJobContext()
|
||||||
|
if rc.Parent != nil && stage == stepStageMain && jobContext.Status != "cancelled" {
|
||||||
|
jobContext.Status = rc.ownStatus()
|
||||||
|
}
|
||||||
|
runStep, err := EvalBool(ctx, rc.newStepExpressionEvaluator(ctx, step, rc.actionInputs, jobContext), expr, defaultStatusCheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
|
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/action"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/action/checkout"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var builtinActions = map[string]func(context.Context, *action.Context) error{
|
||||||
|
"checkout": checkout.Main,
|
||||||
|
}
|
||||||
|
|
||||||
|
type stepActionBuiltin struct {
|
||||||
|
Step *model.Step
|
||||||
|
RunContext *RunContext
|
||||||
|
run func(context.Context, *action.Context) error
|
||||||
|
env map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) pre() common.Executor { return common.NewPipelineExecutor() }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) main() common.Executor {
|
||||||
|
sab.env = map[string]string{}
|
||||||
|
return runStepExecutor(sab, stepStageMain, func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
printRunActionHeader(ctx, sab.Step, sab.env, sab.RunContext)
|
||||||
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
|
defer rawLogger.Infof("::endgroup::")
|
||||||
|
|
||||||
|
rc := sab.RunContext
|
||||||
|
return sab.run(ctx, &action.Context{
|
||||||
|
Container: rc.JobContainer,
|
||||||
|
Github: rc.getGithubContext(ctx),
|
||||||
|
Inputs: sab.Step.With,
|
||||||
|
HostWorkdir: rc.Config.Workdir,
|
||||||
|
Workspace: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||||
|
BindWorkdir: rc.Config.BindWorkdir,
|
||||||
|
NoSkipCheckout: rc.Config.NoSkipCheckout,
|
||||||
|
UseGitIgnore: rc.Config.UseGitIgnore,
|
||||||
|
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) post() common.Executor { return common.NewPipelineExecutor() }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getRunContext() *RunContext { return sab.RunContext }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||||
|
return sab.RunContext.getGithubContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getStepModel() *model.Step { return sab.Step }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getEnv() *map[string]string { return &sab.env }
|
||||||
|
|
||||||
|
func (sab *stepActionBuiltin) getIfExpression(_ context.Context, _ stepStage) string {
|
||||||
|
return sab.Step.If.Value
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -58,13 +57,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
sar.Step.Uses = uses
|
sar.Step.Uses = uses
|
||||||
|
|
||||||
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
||||||
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) {
|
if sar.remoteAction, err = newRemoteAction(sar.Step.Uses, github, sar.RunContext.Config.GitHubInstance); err != nil {
|
||||||
sar.remoteAction = newSelfRepoAction(sar.Step.Uses, github)
|
return err
|
||||||
} else {
|
|
||||||
sar.remoteAction = newRemoteAction(sar.Step.Uses)
|
|
||||||
}
|
|
||||||
if sar.remoteAction == nil {
|
|
||||||
return fmt.Errorf("expected format {org}/{repo}[/path]@ref or %s{path}. Actual '%s' Input string was not in a correct format", selfRepoPrefix, sar.Step.Uses)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
|
||||||
@@ -164,11 +158,7 @@ func (sar *stepActionRemote) main() common.Executor {
|
|||||||
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
|
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
checkoutPath, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.With["path"])
|
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), sar.Step.With["path"])
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate with.path: %w", err)
|
|
||||||
}
|
|
||||||
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath)
|
|
||||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
|
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,61 +314,34 @@ func (ra *remoteAction) IsCheckout() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSelfRepoAction resolves `$/{path}` against the enclosing composite action, falling back to the workflow's own repo and commit.
|
// newRemoteAction resolves `self:` on instanceURL and `$/` in the enclosing composite action, else the workflow's repo and commit.
|
||||||
func newSelfRepoAction(action string, github *model.GithubContext) *remoteAction {
|
func newRemoteAction(action string, github *model.GithubContext, instanceURL string) (*remoteAction, error) {
|
||||||
subPath := strings.TrimLeft(strings.TrimPrefix(action, selfRepoPrefix), "/")
|
uses, err := model.ParseActionUses(action)
|
||||||
if subPath == "" || strings.Contains(subPath, "@") || path.Clean("/"+subPath) != "/"+subPath { // rooted, so a leading ".." is rejected too
|
if err != nil {
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
repo, ref := github.ActionRepository, github.ActionRef
|
ra := &remoteAction{URL: uses.URL, Org: uses.Owner, Repo: uses.Repo, Path: uses.Path, Ref: uses.Ref}
|
||||||
if repo == "" || ref == "" {
|
switch uses.Kind {
|
||||||
repo, ref = github.Repository, github.Sha
|
case model.ActionUsesInstance:
|
||||||
}
|
if instanceURL == "" {
|
||||||
org, name, _ := strings.Cut(repo, "/")
|
return nil, fmt.Errorf("unable to resolve %q without a Gitea instance", action)
|
||||||
if org == "" || name == "" || ref == "" {
|
}
|
||||||
return nil
|
ra.URL = instanceURL
|
||||||
}
|
if !strings.Contains(instanceURL, "://") {
|
||||||
return &remoteAction{URL: github.ServerURL, Org: org, Repo: name, Path: subPath, Ref: ref}
|
ra.URL = "https://" + instanceURL
|
||||||
}
|
}
|
||||||
|
case model.ActionUsesSelfRepo:
|
||||||
func newRemoteAction(action string) *remoteAction {
|
repo, ref := github.ActionRepository, github.ActionRef
|
||||||
// support http(s)://host/owner/repo@v3
|
if repo == "" || ref == "" {
|
||||||
for _, schema := range []string{"https://", "http://", "ssh://"} {
|
repo, ref = github.Repository, github.Sha
|
||||||
if after, ok := strings.CutPrefix(action, schema); ok {
|
}
|
||||||
splits := strings.SplitN(after, "/", 2)
|
ra.Org, ra.Repo, _ = strings.Cut(repo, "/")
|
||||||
if len(splits) != 2 {
|
ra.URL, ra.Ref = github.ServerURL, ref
|
||||||
return nil
|
if ra.Org == "" || ra.Repo == "" || ra.Ref == "" {
|
||||||
}
|
return nil, fmt.Errorf("unable to resolve %q without a repository and commit", action)
|
||||||
ret := parseAction(splits[1])
|
|
||||||
if ret == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ret.URL = schema + splits[0]
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ra, nil
|
||||||
return parseAction(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAction(action string) *remoteAction {
|
|
||||||
// GitHub's document[^] describes:
|
|
||||||
// > We strongly recommend that you include the version of
|
|
||||||
// > the action you are using by specifying a Git ref, SHA, or Docker tag number.
|
|
||||||
// Actually, the workflow stops if there is the uses directive that hasn't @ref.
|
|
||||||
// [^]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
|
|
||||||
r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`)
|
|
||||||
matches := r.FindStringSubmatch(action)
|
|
||||||
if len(matches) < 7 || matches[6] == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &remoteAction{
|
|
||||||
Org: matches[1],
|
|
||||||
Repo: matches[2],
|
|
||||||
Path: matches[4],
|
|
||||||
Ref: matches[6],
|
|
||||||
URL: "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeFilename(s string) string {
|
func safeFilename(s string) string {
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ func TestStepActionRemote(t *testing.T) {
|
|||||||
sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||||
}
|
}
|
||||||
if tt.mocks.run {
|
if tt.mocks.run {
|
||||||
sarm.On("runAction", sar, actionDirSuffix(sar.Step.UsesHash()), newRemoteAction(sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError })
|
sarm.On("runAction", sar, actionDirSuffix(sar.Step.UsesHash()), mustNewRemoteAction(t, sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError })
|
||||||
|
|
||||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor)
|
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor)
|
||||||
|
|
||||||
@@ -249,7 +249,7 @@ func TestStepActionRemote(t *testing.T) {
|
|||||||
RunContext: &RunContext{Config: &Config{ActionCacheDir: t.TempDir()}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, JobContainer: &jobContainerMock{}},
|
RunContext: &RunContext{Config: &Config{ActionCacheDir: t.TempDir()}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, JobContainer: &jobContainerMock{}},
|
||||||
action: &model.Action{Inputs: map[string]model.Input{"shared": {}}},
|
action: &model.Action{Inputs: map[string]model.Input{"shared": {}}},
|
||||||
env: map[string]string{},
|
env: map[string]string{},
|
||||||
remoteAction: newRemoteAction("org/composite@v1"),
|
remoteAction: mustNewRemoteAction(t, "org/composite@v1"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, value := range []string{"first", "second"} {
|
for _, value := range []string{"first", "second"} {
|
||||||
@@ -464,7 +464,7 @@ func TestStepActionRemotePost(t *testing.T) {
|
|||||||
Step: tt.stepModel,
|
Step: tt.stepModel,
|
||||||
action: tt.actionModel,
|
action: tt.actionModel,
|
||||||
// post only ever runs after prepareActionExecutor resolved the action
|
// post only ever runs after prepareActionExecutor resolved the action
|
||||||
remoteAction: newRemoteAction(tt.stepModel.Uses),
|
remoteAction: mustNewRemoteAction(t, tt.stepModel.Uses),
|
||||||
}
|
}
|
||||||
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
@@ -611,10 +611,21 @@ func Test_newRemoteAction(t *testing.T) {
|
|||||||
action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host
|
action: "ssh://gitea.com/onlyonesegment@main", // missing org/repo after the host
|
||||||
want: nil,
|
want: nil,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
action: "self:owner/repo/sub@v1",
|
||||||
|
want: &remoteAction{
|
||||||
|
URL: "https://gitea.example.com",
|
||||||
|
Org: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
Path: "sub",
|
||||||
|
Ref: "v1",
|
||||||
|
},
|
||||||
|
wantCloneURL: "https://gitea.example.com/owner/repo",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.action, func(t *testing.T) {
|
t.Run(tt.action, func(t *testing.T) {
|
||||||
got := newRemoteAction(tt.action)
|
got, _ := newRemoteAction(tt.action, nil, "gitea.example.com")
|
||||||
assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action)
|
assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action)
|
||||||
cloneURL := ""
|
cloneURL := ""
|
||||||
if got != nil {
|
if got != nil {
|
||||||
@@ -623,9 +634,18 @@ func Test_newRemoteAction(t *testing.T) {
|
|||||||
assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action)
|
assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
_, err := newRemoteAction("self:owner/repo@v1", nil, "")
|
||||||
|
require.ErrorContains(t, err, "without a Gitea instance")
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_newSelfRepoAction(t *testing.T) {
|
func mustNewRemoteAction(t *testing.T, uses string) *remoteAction {
|
||||||
|
t.Helper()
|
||||||
|
action, err := newRemoteAction(uses, nil, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_newRemoteActionSelfRepo(t *testing.T) {
|
||||||
workflow := &model.GithubContext{
|
workflow := &model.GithubContext{
|
||||||
ServerURL: "https://gitea.example.com",
|
ServerURL: "https://gitea.example.com",
|
||||||
Repository: "owner/workflow-repo",
|
Repository: "owner/workflow-repo",
|
||||||
@@ -674,7 +694,8 @@ func Test_newSelfRepoAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
assert.Equal(t, tt.want, newSelfRepoAction(tt.action, tt.github))
|
got, _ := newRemoteAction(tt.action, tt.github, "")
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,7 +725,7 @@ func Test_remoteActionReference(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.uses, func(t *testing.T) {
|
t.Run(tt.uses, func(t *testing.T) {
|
||||||
assert.Equal(t, tt.want, newRemoteAction(tt.uses).Reference())
|
assert.Equal(t, tt.want, mustNewRemoteAction(t, tt.uses).Reference())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,7 +782,7 @@ func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
|
|||||||
func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
||||||
t.Run("reports the action and its resolved commit", func(t *testing.T) {
|
t.Run("reports the action and its resolved commit", func(t *testing.T) {
|
||||||
sar := &stepActionRemote{
|
sar := &stepActionRemote{
|
||||||
remoteAction: newRemoteAction("actions/checkout@v7"),
|
remoteAction: mustNewRemoteAction(t, "actions/checkout@v7"),
|
||||||
action: &model.Action{},
|
action: &model.Action{},
|
||||||
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
||||||
}
|
}
|
||||||
@@ -775,7 +796,7 @@ func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
|
t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
|
||||||
// The local checkout of the workflow's own repository resolves no action.
|
// The local checkout of the workflow's own repository resolves no action.
|
||||||
sar := &stepActionRemote{remoteAction: newRemoteAction("actions/checkout@v7")}
|
sar := &stepActionRemote{remoteAction: mustNewRemoteAction(t, "actions/checkout@v7")}
|
||||||
|
|
||||||
_, _, ok := sar.actionDownloadInfo()
|
_, _, ok := sar.actionDownloadInfo()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -58,29 +57,17 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
|
|||||||
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
image := strings.TrimPrefix(step.Uses, "docker://")
|
image := strings.TrimPrefix(step.Uses, "docker://")
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
cmd, err := shellquote.Split(step.With["args"])
|
||||||
args, err := eval.Interpolate(ctx, step.With["args"])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate with.args: %w", err)
|
|
||||||
}
|
|
||||||
cmd, err := shellquote.Split(args)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var entrypoint []string
|
var entrypoint []string
|
||||||
entry, err := eval.Interpolate(ctx, step.With["entrypoint"])
|
if entry := step.With["entrypoint"]; entry != "" {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
|
|
||||||
}
|
|
||||||
if entry != "" {
|
|
||||||
entrypoint = []string{entry}
|
entrypoint = []string{entry}
|
||||||
}
|
}
|
||||||
|
|
||||||
stepContainer, err := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
|
stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
stepContainer.Pull(rc.Config.ForcePull),
|
stepContainer.Pull(rc.Config.ForcePull),
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
_, _ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
|
newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
|
||||||
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
|
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -214,7 +214,7 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
|||||||
require.NoError(t, sd.RunContext.resolvePlatformImage(ctx))
|
require.NoError(t, sd.RunContext.resolvePlatformImage(ctx))
|
||||||
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform)
|
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform)
|
||||||
|
|
||||||
_, _ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
|
newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
|
||||||
|
|
||||||
if tc.expectDefault {
|
if tc.expectDefault {
|
||||||
assert.Equal(t, "default", captured.NetworkMode,
|
assert.Equal(t, "default", captured.NetworkMode,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
@@ -33,6 +36,18 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step,
|
|||||||
runAction: runActionImpl,
|
runAction: runActionImpl,
|
||||||
}, nil
|
}, nil
|
||||||
case model.StepTypeUsesActionRemote:
|
case model.StepTypeUsesActionRemote:
|
||||||
|
if name, ok := strings.CutPrefix(stepModel.Uses, "builtin:"); ok {
|
||||||
|
run, ok := builtinActions[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown built-in action %q (known: %v) for job:%s step:%+v",
|
||||||
|
name, slices.Sorted(maps.Keys(builtinActions)), rc.Run, stepModel)
|
||||||
|
}
|
||||||
|
return &stepActionBuiltin{
|
||||||
|
Step: stepModel,
|
||||||
|
RunContext: rc,
|
||||||
|
run: run,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
return &stepActionRemote{
|
return &stepActionRemote{
|
||||||
Step: stepModel,
|
Step: stepModel,
|
||||||
RunContext: rc,
|
RunContext: rc,
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ func TestStepFactoryNewStep(t *testing.T) {
|
|||||||
return ok
|
return ok
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "StepBuiltinAction",
|
||||||
|
model: &model.Step{
|
||||||
|
Uses: "builtin:checkout",
|
||||||
|
},
|
||||||
|
check: func(s step) bool {
|
||||||
|
_, ok := s.(*stepActionBuiltin)
|
||||||
|
return ok
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
@@ -72,14 +82,11 @@ func TestStepFactoryNewStep(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStepFactoryInvalidStep(t *testing.T) {
|
func TestStepFactoryInvalidStep(t *testing.T) {
|
||||||
model := &model.Step{
|
for _, stepModel := range []*model.Step{
|
||||||
Uses: "remote/action@v1",
|
{Uses: "remote/action@v1", Run: "cmd"},
|
||||||
Run: "cmd",
|
{Uses: "builtin:unknown"},
|
||||||
|
} {
|
||||||
|
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{})
|
||||||
|
assert.Error(t, err, stepModel.Uses)
|
||||||
}
|
}
|
||||||
|
|
||||||
sf := &stepFactoryImpl{}
|
|
||||||
|
|
||||||
_, err := sf.newStep(model, &RunContext{})
|
|
||||||
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
}
|
||||||
+95
-5
@@ -8,17 +8,24 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/telemetry"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||||
|
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,11 +155,8 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
},
|
},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
}
|
}
|
||||||
step := &model.Step{
|
step := &model.Step{}
|
||||||
With: map[string]string{
|
require.NoError(t, step.RawWith.Encode(map[string]string{"STEP_WITH": "with-value", "ID": "${{ fromJSON('1234567') }}"}))
|
||||||
"STEP_WITH": "with-value",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
|
|
||||||
sm.On("getRunContext").Return(rc)
|
sm.On("getRunContext").Return(rc)
|
||||||
@@ -161,6 +165,7 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
sm.On("getEnv").Return(&env)
|
sm.On("getEnv").Return(&env)
|
||||||
|
|
||||||
require.NoError(t, setupEnv(context.Background(), sm))
|
require.NoError(t, setupEnv(context.Background(), sm))
|
||||||
|
require.NoError(t, setupInputs(context.Background(), sm))
|
||||||
|
|
||||||
// These are commit or system specific
|
// These are commit or system specific
|
||||||
delete(env, "GITHUB_REF")
|
delete(env, "GITHUB_REF")
|
||||||
@@ -197,6 +202,7 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
"GITHUB_RUN_NUMBER": "1",
|
"GITHUB_RUN_NUMBER": "1",
|
||||||
"GITHUB_SERVER_URL": "https://",
|
"GITHUB_SERVER_URL": "https://",
|
||||||
"GITHUB_WORKFLOW": "",
|
"GITHUB_WORKFLOW": "",
|
||||||
|
"INPUT_ID": "1234567",
|
||||||
"INPUT_STEP_WITH": "with-value",
|
"INPUT_STEP_WITH": "with-value",
|
||||||
"RC_KEY": "rcvalue",
|
"RC_KEY": "rcvalue",
|
||||||
"RUNNER_ENVIRONMENT": "self-hosted",
|
"RUNNER_ENVIRONMENT": "self-hosted",
|
||||||
@@ -205,6 +211,67 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
}, env)
|
}, env)
|
||||||
|
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
|
|
||||||
|
for _, expression := range []string{"inputs.args", "env.ARGS"} {
|
||||||
|
t.Run("deferred inputs from "+expression, func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
env: ${{ fromJSON(matrix.env) }}
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
env:
|
||||||
|
PRIORITY: ${{ matrix.priority }}
|
||||||
|
defaults:
|
||||||
|
run: ${{ fromJSON(matrix.defaults) }}
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
env:
|
||||||
|
CONTAINER: ${{ env.PRIORITY }}
|
||||||
|
steps:
|
||||||
|
- uses: ./act
|
||||||
|
env:
|
||||||
|
ARGS: ${{ inputs.args }}
|
||||||
|
with: ${{ fromJSON(` + expression + `) }}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
job := workflow.GetJob("test")
|
||||||
|
rawEnv := model.CloneYamlNode(workflow.RawEnv)
|
||||||
|
rawWith := model.CloneYamlNode(job.Steps[0].RawWith)
|
||||||
|
for _, value := range []string{"first", "second"} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, map[string]any{"env": `{"WORKFLOW":"` + value + `${{ github.job }}","PRIORITY":"workflow"}`, "priority": "${{ github.job }}", "defaults": `{"shell":"` + value + `"}`})
|
||||||
|
require.NoError(t, err)
|
||||||
|
rc.workflowCallInputs = map[string]any{"args": `{"name":"` + value + `${{ github.job }}","fetch-depth":1234567}`}
|
||||||
|
enabled, err := rc.isEnabled(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, enabled)
|
||||||
|
require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc))
|
||||||
|
assert.Equal(t, model.RunDefaults{Shell: value}, rc.jobRunDefaults)
|
||||||
|
step := &stepRun{RunContext: rc, Step: job.Steps[0].Clone(), env: map[string]string{}}
|
||||||
|
require.NoError(t, setupEnv(t.Context(), step))
|
||||||
|
require.NoError(t, setupInputs(t.Context(), step))
|
||||||
|
assert.Equal(t, map[string]string{"ARGS": `${{ inputs.args }}`, "INPUT_NAME": value + "${{ github.job }}", "INPUT_FETCH-DEPTH": "1234567"}, step.Step.GetEnv())
|
||||||
|
assert.Equal(t, value+"${{ github.job }}", step.env["INPUT_NAME"])
|
||||||
|
assert.Equal(t, value+"${{ github.job }}", step.env["WORKFLOW"])
|
||||||
|
assert.Equal(t, "${{ github.job }}", step.env["PRIORITY"])
|
||||||
|
assert.Equal(t, "${{ github.job }}", step.env["CONTAINER"])
|
||||||
|
assert.Equal(t, rawEnv, workflow.RawEnv)
|
||||||
|
assert.Equal(t, rawWith, job.Steps[0].RawWith)
|
||||||
|
assert.Nil(t, workflow.Env)
|
||||||
|
assert.Nil(t, job.Steps[0].With)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("rejects inputs that still contain an expression", func(t *testing.T) {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n test:\n steps:\n - uses: ./act\n with: ${{ inputs.args }}\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
rc, err := (&runnerImpl{config: &Config{}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
rc.workflowCallInputs = map[string]any{"args": "${{ inputs.unresolved }}"}
|
||||||
|
require.ErrorContains(t, setupInputs(t.Context(), &stepRun{RunContext: rc, Step: workflow.Jobs["test"].Steps[0].Clone(), env: map[string]string{}}), "with:")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsStepEnabled(t *testing.T) {
|
func TestIsStepEnabled(t *testing.T) {
|
||||||
@@ -486,6 +553,29 @@ func TestRunStepExecutorParity(t *testing.T) {
|
|||||||
assert.Equal(t, "child", childRC.IntraActionState[child.Step.ID]["saved"])
|
assert.Equal(t, "child", childRC.IntraActionState[child.Step.ID]["saved"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("stages and composite steps are traced under Gitea's step names", func(t *testing.T) {
|
||||||
|
spans := tracetest.NewSpanRecorder()
|
||||||
|
telemetry.SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans)))
|
||||||
|
t.Cleanup(func() { telemetry.SetTracerProvider(tracenoop.NewTracerProvider()) })
|
||||||
|
ctx, _ := telemetry.StartJob(t.Context(), &runnerv1.Task{Id: 7})
|
||||||
|
outer := newStep(t, &model.Step{ID: "outer", Uses: "actions/cache@v4", Number: 3})
|
||||||
|
child := newStep(t, &model.Step{ID: "child", Name: "Restore", Number: 1})
|
||||||
|
skipped := newStep(t, &model.Step{ID: "skipped", Name: "Deploy", Number: 4, If: yaml.Node{Value: "false"}})
|
||||||
|
broken := newStep(t, &model.Step{ID: "broken", Name: "Broken", Number: 5, If: yaml.Node{Value: badExpression}})
|
||||||
|
|
||||||
|
require.NoError(t, runStepExecutor(outer, stepStagePost, runStepExecutor(child, stepStageMain, noopExecutor))(ctx))
|
||||||
|
require.NoError(t, runStepExecutor(skipped, stepStageMain, noopExecutor)(ctx))
|
||||||
|
require.Error(t, runStepExecutor(broken, stepStageMain, noopExecutor)(ctx))
|
||||||
|
|
||||||
|
ended := spans.Ended()
|
||||||
|
require.Len(t, ended, 4)
|
||||||
|
assert.Equal(t, []string{"Restore", "Post Run actions/cache@v4", "Deploy", "Broken"}, []string{ended[0].Name(), ended[1].Name(), ended[2].Name(), ended[3].Name()})
|
||||||
|
assert.Equal(t, ended[1].SpanContext().SpanID(), ended[0].Parent().SpanID())
|
||||||
|
assert.Contains(t, ended[0].Attributes(), semconv.CICDPipelineTaskRunID("7.3.post.1"))
|
||||||
|
assert.Contains(t, ended[2].Attributes(), semconv.CICDPipelineTaskRunResultSkip)
|
||||||
|
assert.Contains(t, ended[3].Attributes(), semconv.CICDPipelineTaskRunResultFailure)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("timeout must be positive", func(t *testing.T) {
|
t.Run("timeout must be positive", func(t *testing.T) {
|
||||||
exprEval := createRunContext(t).NewExpressionEvaluator(context.Background())
|
exprEval := createRunContext(t).NewExpressionEvaluator(context.Background())
|
||||||
for timeout, wantDeadline := range map[string]bool{"-1": false, "0": false, "1": true} {
|
for timeout, wantDeadline := range map[string]bool{"-1": false, "0": false, "1": true} {
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
name: builtin-checkout
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: builtin:checkout
|
||||||
|
with:
|
||||||
|
path: ${{ 'sub' }}
|
||||||
|
- run: test -f sub/builtin-checkout/push.yml
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
name: docker-proxy
|
||||||
|
description: Verify mounted Docker access through post steps
|
||||||
|
runs:
|
||||||
|
using: node24
|
||||||
|
main: index.js
|
||||||
|
post: index.js
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {once} = require('node:events');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const http = require('node:http');
|
||||||
|
|
||||||
|
async function request(method, path, body) {
|
||||||
|
const req = http.request({
|
||||||
|
socketPath: '/var/run/docker.sock',
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
req.end(JSON.stringify(body));
|
||||||
|
const [res] = await once(req, 'response');
|
||||||
|
req.on('error', (error) => res.destroy(error));
|
||||||
|
let data = '';
|
||||||
|
for await (const chunk of res.setEncoding('utf8')) {
|
||||||
|
data += chunk;
|
||||||
|
}
|
||||||
|
assert(res.statusCode >= 200 && res.statusCode < 300, `${method} ${path}: ${res.statusCode} ${data}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
assert(fs.statSync('/var/run/docker.sock').isSocket(), 'Docker mount must be a socket');
|
||||||
|
assert.equal(await request('GET', '/_ping'), 'OK');
|
||||||
|
const api = `/v${JSON.parse(await request('GET', '/version')).ApiVersion}`;
|
||||||
|
const name = process.env.PROXY_TEST_RESOURCE;
|
||||||
|
const job = process.env.JOB_CONTAINER_NAME;
|
||||||
|
const post = process.env.STATE_post === 'true';
|
||||||
|
assert(name);
|
||||||
|
assert(job);
|
||||||
|
if (!post) {
|
||||||
|
await request('POST', `${api}/volumes/create`, {Name: name});
|
||||||
|
await request('POST', `${api}/networks/create`, {Name: name});
|
||||||
|
await request('POST', `${api}/networks/${name}/connect`, {Container: job});
|
||||||
|
fs.appendFileSync(process.env.GITHUB_STATE, 'post=true\n');
|
||||||
|
}
|
||||||
|
const label = process.env.PROXY_TEST_MODE === 'proxy' ? job : undefined;
|
||||||
|
assert.equal(JSON.parse(await request('GET', `${api}/volumes/${name}`)).Labels?.['com.gitea.runner.job'], label);
|
||||||
|
const network = JSON.parse(await request('GET', `${api}/networks/${name}`));
|
||||||
|
assert.equal(network.Labels?.['com.gitea.runner.job'], label);
|
||||||
|
assert(Object.values(network.Containers).some((container) => container.Name === job));
|
||||||
|
if (post) {
|
||||||
|
console.log('docker proxy post verified');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
name: docker-proxy
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
proxy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./action
|
||||||
|
- if: env.PROXY_TEST_MODE == 'proxy'
|
||||||
|
run: |
|
||||||
|
docker run -d --restart always --network "$PROXY_TEST_RESOURCE" --name "$PROXY_TEST_RESOURCE-detached" "$PROXY_TEST_IMAGE" sleep infinity
|
||||||
|
echo "{services: {app: {image: $PROXY_TEST_IMAGE, command: sleep infinity, volumes: [named:/named]}}, volumes: {named: {}}}" | docker compose -f - up -d
|
||||||
|
binds:
|
||||||
|
needs: proxy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
test -z "$(docker ps -aq -f name="$PROXY_TEST_RESOURCE-detached" -f name="$PROXY_TEST_RESOURCE-app"; docker volume ls -q -f name="${PROXY_TEST_RESOURCE}_named"; docker network ls -q -f name="${PROXY_TEST_RESOURCE}_default")"
|
||||||
|
mkdir data && echo from-job > data/marker
|
||||||
|
- id: run
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "${{ github.workspace }}:/src:ro" -v "$PWD:$PWD" -v ./data/created:/created -v "$GITHUB_OUTPUT:/output" \
|
||||||
|
--mount "type=bind,src=$PWD/data/../data,dst=/spelled,readonly" \
|
||||||
|
--mount "type=volume,dst=/device,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=$PWD/data" \
|
||||||
|
"$PROXY_TEST_IMAGE" sh -c 'cat /src/data/marker "$0/data/marker" /spelled/marker /device/marker | grep -c from-job | grep -qx 4 && echo from-nested > /created/file && echo value=from-nested >> /output' "$PWD"
|
||||||
|
grep -qx from-nested data/created/file
|
||||||
|
- run: |
|
||||||
|
test "${{ steps.run.outputs.value }}" = from-nested
|
||||||
|
cat > compose.yaml <<'EOF'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${PROXY_TEST_IMAGE}
|
||||||
|
command: cat /short/marker /long/marker /named/marker
|
||||||
|
volumes:
|
||||||
|
- ./data:/short
|
||||||
|
- type: bind
|
||||||
|
source: ./data
|
||||||
|
target: /long
|
||||||
|
bind:
|
||||||
|
create_host_path: false
|
||||||
|
- named:/named
|
||||||
|
volumes:
|
||||||
|
named:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: ./data
|
||||||
|
EOF
|
||||||
|
test "$(docker compose run --rm app | grep -c from-job)" = 3
|
||||||
|
docker compose down --volumes
|
||||||
|
- run: |
|
||||||
|
docker run --rm --user 1001 --group-add 2375 -v /var/run/docker.sock:/var/run/docker.sock -v /usr/local/bin/docker:/usr/local/bin/docker:ro -v "${GITEA_DOCKER_WORKSPACE:?}/data:/data:ro" \
|
||||||
|
"$PROXY_TEST_IMAGE" sh -c 'grep -qx from-job /data/marker && docker volume create "$0"' "$PROXY_TEST_RESOURCE-nested"
|
||||||
|
test "$(docker volume inspect -f '{{ index .Labels "com.gitea.runner.job" }}' "$PROXY_TEST_RESOURCE-nested")" = "$JOB_CONTAINER_NAME"
|
||||||
|
echo "docker binds verified"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Built-in actions
|
||||||
|
|
||||||
|
Built-in actions ship with the runner, so a job needs no action download and no Node in its image.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: builtin:checkout
|
||||||
|
```
|
||||||
|
|
||||||
|
## checkout
|
||||||
|
|
||||||
|
Works like `actions/checkout` with these inputs. Any other input fails the step.
|
||||||
|
|
||||||
|
| Input | Default |
|
||||||
|
| --- | --- |
|
||||||
|
| `repository` | `github.repository` |
|
||||||
|
| `ref` | the triggering commit, or the default branch of another repository |
|
||||||
|
| `token` | `github.token` |
|
||||||
|
| `path` | workspace root |
|
||||||
|
| `fetch-depth` | `1`, `0` for full history |
|
||||||
|
|
||||||
|
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` is set.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# OpenTelemetry tracing
|
||||||
|
|
||||||
|
The runner can send a trace of every job it runs to an OpenTelemetry collector or any backend that accepts OTLP. Each job becomes a trace with a span per step, so you can see where CI time goes and which steps fail. Tracing stays off until you set an endpoint.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Set the endpoint in the runner's environment, for example in its systemd unit, with `docker run -e` or in the Kubernetes pod spec:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector base URL, traces go to `/v1/traces` under it |
|
||||||
|
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces URL, takes precedence over the base URL |
|
||||||
|
| `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | Request headers such as an API key, as comma-separated `key=value` pairs with URL-encoded values |
|
||||||
|
| `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES` | Resource attributes, the service name defaults to `gitea-runner` |
|
||||||
|
| `OTEL_TRACES_SAMPLER`, `OTEL_BSP_*` | Sampling and batching, see the [SDK variables](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) |
|
||||||
|
| `OTEL_SDK_DISABLED=true`, `OTEL_TRACES_EXPORTER=none` | Turn tracing off |
|
||||||
|
|
||||||
|
Only the `http/protobuf` protocol is supported.
|
||||||
|
|
||||||
|
## What is sent
|
||||||
|
|
||||||
|
- A span per job, named `RUN {workflow} {job}`, with the repository, ref, commit, run URL, run number, attempt and result.
|
||||||
|
- A span per step, named as in Gitea's step list, including `Pre` and `Post` stages and the steps of composite actions, with the step's result.
|
||||||
|
- The runner's name, UUID and version.
|
||||||
|
|
||||||
|
Logs, step output and secrets are never sent. Step names are taken from the workflow file as written, without expanding `${{ }}` expressions.
|
||||||
|
|
||||||
|
## Tracing inside jobs
|
||||||
|
|
||||||
|
Jobs receive `TRACEPARENT`, so tools in a job that use OpenTelemetry can add their spans to the job's trace. To let them export, set their `OTEL_*` variables in `runner.envs`, see [Security](#security).
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
Metrics stay on the Prometheus endpoint enabled by `metrics.enabled`. To get them into OpenTelemetry, scrape `/metrics` with the collector's `prometheus` receiver.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- OTLP has no authentication of its own. Send to a collector on the runner's host that holds the backend credentials, as the [OpenTelemetry security guide](https://opentelemetry.io/docs/security/config-best-practices/) recommends, or use `https` with a token in `OTEL_EXPORTER_OTLP_HEADERS`. Over plain `http`, headers travel unencrypted.
|
||||||
|
- TLS certificates are verified against the system CA bundle, set `SSL_CERT_FILE` to use your own. Client certificates are not supported, use a local collector for mTLS. Redirects are not followed, so headers only reach the configured endpoint.
|
||||||
|
- Jobs in containers never see the runner's `OTEL_*` variables. Jobs on the host inherit the runner's whole environment, headers included, and can read its files anyway since they run as the same user.
|
||||||
|
- Every workflow can read `runner.envs`, so never put credentials there. Point jobs at a collector that holds them instead, keeping in mind that any workflow can then send data through it.
|
||||||
|
- Traces carry the repository and step names of every job, private repositories included, so give the collector the same access controls as the runner's logs.
|
||||||
@@ -28,3 +28,24 @@ func testActionsCacheRoundTrip(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testArtifactRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
v2 := true
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
options runnerOptions
|
||||||
|
}{
|
||||||
|
{"through_the_cache_server", runnerOptions{cacheV2: &v2}},
|
||||||
|
{"direct_with_no_reachable_cache", runnerOptions{cacheHost: "cache.invalid", cacheV2: new(bool)}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
api, repo, _ := startIsolatedScenario(t, "artifact.yml", "e2e-artifact", tc.options)
|
||||||
|
|
||||||
|
wfRun := waitForRun(t, api, repo)
|
||||||
|
requireSuccess(t, api, repo, wfRun.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-6
@@ -51,23 +51,29 @@ func testRunCancellation(t *testing.T) {
|
|||||||
|
|
||||||
func waitForRunningJobLog(t *testing.T, api *GiteaAPI, repo string, runID int64, substr string) {
|
func waitForRunningJobLog(t *testing.T, api *GiteaAPI, repo string, runID int64, substr string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
// The budget covers scheduling and the container start, which a loaded runner makes slow.
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), runTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
status := "none"
|
||||||
for {
|
for {
|
||||||
jobs, err := api.Jobs(ctx, repo, runID)
|
jobs, err := api.Jobs(ctx, repo, runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list jobs: %v", err)
|
t.Fatalf("list jobs: %v", err)
|
||||||
}
|
}
|
||||||
if len(jobs) > 0 && jobs[0].Status == "in_progress" {
|
if len(jobs) > 0 {
|
||||||
logs, err := api.JobLogs(ctx, repo, jobs[0].ID)
|
status = jobs[0].Status
|
||||||
if err == nil && commandRow(logs, substr) == substr {
|
if status == "in_progress" {
|
||||||
return
|
logs, err := api.JobLogs(ctx, repo, jobs[0].ID)
|
||||||
|
if err == nil && commandRow(logs, substr) == substr {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
t.Fatalf("running job for run %d never logged %q", runID, substr)
|
dumpRunLogs(t, api, repo, runID)
|
||||||
|
t.Fatalf("job of run %d is %q and never logged %q", runID, status, substr)
|
||||||
case <-time.After(pollInterval):
|
case <-time.After(pollInterval):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func TestCompatibility(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("payloads", testPayloads)
|
t.Run("payloads", testPayloads)
|
||||||
t.Run("cache", testActionsCacheRoundTrip)
|
t.Run("cache", testActionsCacheRoundTrip)
|
||||||
|
t.Run("artifact", testArtifactRoundTrip)
|
||||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||||
t.Run("dispatch", testWorkflowDispatch)
|
t.Run("dispatch", testWorkflowDispatch)
|
||||||
t.Run("ephemeral", testEphemeralRunner)
|
t.Run("ephemeral", testEphemeralRunner)
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
|||||||
sharedNet, _ = selfNetwork(ctx, cli)
|
sharedNet, _ = selfNetwork(ctx, cli)
|
||||||
)
|
)
|
||||||
if sharedNet != "" {
|
if sharedNet != "" {
|
||||||
baseURL = fmt.Sprintf("http://%s:3000", name)
|
baseURL = "http://" + net.JoinHostPort(name, "3000")
|
||||||
netConfig = &network.NetworkingConfig{
|
netConfig = &network.NetworkingConfig{
|
||||||
EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}},
|
EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}},
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("find a free host port: %w", err)
|
return nil, fmt.Errorf("find a free host port: %w", err)
|
||||||
}
|
}
|
||||||
baseURL = fmt.Sprintf("http://%s:%d", host, port)
|
baseURL = "http://" + net.JoinHostPort(host.String(), strconv.Itoa(port))
|
||||||
hostConfig.PortBindings = network.PortMap{
|
hostConfig.PortBindings = network.PortMap{
|
||||||
containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}},
|
containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}},
|
||||||
}
|
}
|
||||||
@@ -360,6 +360,6 @@ func (f *GiteaFixture) Close(ctx context.Context) error {
|
|||||||
if f.id == "" {
|
if f.id == "" {
|
||||||
return f.cli.Close()
|
return f.cli.Close()
|
||||||
}
|
}
|
||||||
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true})
|
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||||
return errors.Join(removeErr, f.cli.Close())
|
return errors.Join(removeErr, f.cli.Close())
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ type runnerOptions struct {
|
|||||||
capacity int
|
capacity int
|
||||||
ephemeral bool
|
ephemeral bool
|
||||||
cacheV2 *bool
|
cacheV2 *bool
|
||||||
|
cacheHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
||||||
@@ -49,6 +50,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
|||||||
cfg.Container.DockerHost = "unix:///var/run/docker.sock"
|
cfg.Container.DockerHost = "unix:///var/run/docker.sock"
|
||||||
}
|
}
|
||||||
cfg.Cache.Dir = t.TempDir() + "/cache"
|
cfg.Cache.Dir = t.TempDir() + "/cache"
|
||||||
|
cfg.Cache.Host = options.cacheHost
|
||||||
cfg.Runner.Insecure = true
|
cfg.Runner.Insecure = true
|
||||||
cfg.Runner.FetchInterval = 250 * time.Millisecond // faster than prod defaults for local fixture
|
cfg.Runner.FetchInterval = 250 * time.Millisecond // faster than prod defaults for local fixture
|
||||||
cfg.Runner.FetchIntervalMax = 250 * time.Millisecond
|
cfg.Runner.FetchIntervalMax = 250 * time.Millisecond
|
||||||
|
|||||||
Vendored
+28
@@ -0,0 +1,28 @@
|
|||||||
|
name: artifact
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
upload:
|
||||||
|
runs-on: e2e-artifact
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
echo "results=$ACTIONS_RESULTS_URL cache=$ACTIONS_CACHE_URL v2=${ACTIONS_CACHE_SERVICE_V2:-unset}"
|
||||||
|
if [ "$ACTIONS_CACHE_SERVICE_V2" = "true" ]; then
|
||||||
|
test "$ACTIONS_RESULTS_URL/" = "$ACTIONS_CACHE_URL"
|
||||||
|
else
|
||||||
|
test "$ACTIONS_RESULTS_URL/" != "$ACTIONS_CACHE_URL"
|
||||||
|
fi
|
||||||
|
mkdir -p out
|
||||||
|
echo "artifact-payload" > out/data.txt
|
||||||
|
- uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: e2e-artifact-${{ github.run_id }}
|
||||||
|
path: out/data.txt
|
||||||
|
download:
|
||||||
|
needs: upload
|
||||||
|
runs-on: e2e-artifact
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: e2e-artifact-${{ github.run_id }}
|
||||||
|
path: got
|
||||||
|
- run: grep -q artifact-payload got/data.txt
|
||||||
Vendored
+1
-1
@@ -6,4 +6,4 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- run: |
|
- run: |
|
||||||
echo e2e-live-log-marker
|
echo e2e-live-log-marker
|
||||||
timeout 2s tail -f /dev/null || true
|
timeout 10s tail -f /dev/null || true
|
||||||
@@ -2,50 +2,56 @@ module gitea.com/gitea/runner
|
|||||||
|
|
||||||
go 1.27
|
go 1.27
|
||||||
|
|
||||||
toolchain go1.27.0
|
toolchain go1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.20.0
|
connectrpc.com/connect v1.21.0
|
||||||
dario.cat/mergo v1.0.2
|
dario.cat/mergo v1.0.2
|
||||||
gitea.dev/actionslib v0.7.0
|
gitea.dev/actionslib v1.2.1
|
||||||
github.com/avast/retry-go/v5 v5.0.0
|
github.com/avast/retry-go/v5 v5.0.0
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v29.7.2+incompatible
|
github.com/docker/cli v29.8.1+incompatible
|
||||||
github.com/docker/go-connections v0.8.1
|
github.com/docker/go-connections v0.8.1
|
||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
github.com/go-git/go-billy/v5 v5.9.1
|
github.com/go-git/go-billy/v5 v5.9.1
|
||||||
github.com/go-git/go-git/v5 v5.19.2
|
github.com/go-git/go-git/v5 v5.19.2
|
||||||
github.com/gobwas/glob v0.2.3
|
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
|
github.com/johannesboyne/gofakes3 v1.2.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/julienschmidt/httprouter v1.3.0
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||||
github.com/mattn/go-isatty v0.0.24
|
github.com/mattn/go-isatty v0.0.24
|
||||||
github.com/moby/go-archive v0.3.3
|
github.com/moby/go-archive v0.3.3
|
||||||
github.com/moby/moby/api v1.55.0
|
github.com/moby/moby/api v1.56.0
|
||||||
github.com/moby/moby/client v0.5.1
|
github.com/moby/moby/client v0.6.0
|
||||||
github.com/moby/patternmatcher v0.6.1
|
github.com/moby/patternmatcher v0.6.1
|
||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.1
|
||||||
github.com/prometheus/client_golang v1.24.1
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/prometheus/client_model v0.6.2
|
github.com/prometheus/client_model v0.6.3
|
||||||
github.com/sirupsen/logrus v1.10.1
|
github.com/sirupsen/logrus v1.10.2
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.12.1
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||||
go.etcd.io/bbolt v1.5.0
|
go.etcd.io/bbolt v1.5.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
go.opentelemetry.io/otel v1.46.0
|
||||||
golang.org/x/net v0.58.0
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0
|
||||||
golang.org/x/sync v0.22.0
|
go.opentelemetry.io/otel/sdk v1.46.0
|
||||||
golang.org/x/sys v0.47.0
|
go.opentelemetry.io/otel/trace v1.46.0
|
||||||
golang.org/x/term v0.45.0
|
go.opentelemetry.io/proto/otlp v1.11.0
|
||||||
golang.org/x/text v0.41.0
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
|
golang.org/x/net v0.59.0
|
||||||
|
golang.org/x/sync v0.23.0
|
||||||
|
golang.org/x/sys v0.48.0
|
||||||
|
golang.org/x/term v0.46.0
|
||||||
|
golang.org/x/text v0.42.0
|
||||||
google.golang.org/protobuf v1.36.12
|
google.golang.org/protobuf v1.36.12
|
||||||
gotest.tools/v3 v3.5.2
|
gotest.tools/v3 v3.5.2
|
||||||
tags.cncf.io/container-device-interface v1.1.0
|
tags.cncf.io/container-device-interface v1.1.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -53,16 +59,13 @@ require (
|
|||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
|
||||||
github.com/cloudflare/circl v1.6.5 // indirect
|
github.com/cloudflare/circl v1.6.5 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.7.0 // indirect
|
github.com/cyphar/filepath-securejoin v0.7.0 // indirect
|
||||||
github.com/docker/docker-credential-helpers v0.9.8 // indirect
|
github.com/docker/docker-credential-helpers v0.9.8 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
github.com/go-logr/logr v1.4.4 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
@@ -70,14 +73,12 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.19.2 // indirect
|
github.com/klauspost/compress v1.19.2 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.28 // indirect
|
|
||||||
github.com/mattn/go-shellwords v1.0.14 // indirect
|
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||||
github.com/moby/sys/user v0.4.1 // indirect
|
github.com/moby/sys/user v0.4.1 // indirect
|
||||||
@@ -87,21 +88,18 @@ require (
|
|||||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/prometheus/common v0.70.1 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
github.com/prometheus/procfs v0.21.1 // indirect
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
github.com/rhysd/actionlint v1.7.12 // indirect
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||||
github.com/sergi/go-diff v1.4.0 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
github.com/stretchr/objx v0.5.3 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.45.0 // indirect
|
go.opentelemetry.io/otel/metric v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/crypto v0.56.0 // indirect
|
golang.org/x/crypto v0.57.0 // indirect
|
||||||
|
golang.org/x/tools v0.49.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
)
|
)
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs=
|
||||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||||
cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik=
|
cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik=
|
||||||
cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
gitea.dev/actionslib v0.7.0 h1:JCV8eeIGwjlXcuSr7ojEdQC22VoE2466+K+D9vuQWKQ=
|
gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0=
|
||||||
gitea.dev/actionslib v0.7.0/go.mod h1:DI3Lqp+8TrycM7/semMdqsDQOHaBskjIAuMn+SXfZJ0=
|
gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
@@ -19,14 +19,40 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
|
|||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k=
|
github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k=
|
||||||
github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8=
|
github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||||
|
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||||
|
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
|
||||||
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
|
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
|
||||||
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
|
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
@@ -44,8 +70,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY=
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/docker/cli v29.8.1+incompatible h1:qYL1bCp6cRw2SB1xmLlIOPyV171dilw9W2Jew38vy9c=
|
||||||
|
github.com/docker/cli v29.8.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q=
|
github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q=
|
||||||
github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||||
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||||
@@ -56,8 +84,6 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o
|
|||||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
|
||||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
|
||||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
@@ -77,8 +103,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
|
||||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
@@ -91,6 +115,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
|||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
|
github.com/johannesboyne/gofakes3 v1.2.0 h1:I9VEzPWvvAUAGzDlhYFoZjF0AXMlkcEyZlmBwiI6Oms=
|
||||||
|
github.com/johannesboyne/gofakes3 v1.2.0/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||||
@@ -112,22 +138,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
|
|
||||||
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
|
||||||
github.com/mattn/go-shellwords v1.0.14 h1:yUKzIgsCnosndOASY6/enly1EAuaXeFSQ7cdyA3OuYg=
|
|
||||||
github.com/mattn/go-shellwords v1.0.14/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
|
github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
|
||||||
github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
|
github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
|
||||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ=
|
||||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
|
||||||
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs=
|
||||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ=
|
||||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
|
github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
|
||||||
@@ -157,26 +177,28 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=
|
|
||||||
github.com/rhysd/actionlint v1.7.12/go.mod h1:krOUhujIsJusovkaYzQ/VNH8PFexjNKqU0q5XI/4w+g=
|
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
|
||||||
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
|
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||||
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
|
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||||
|
github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
|
||||||
|
github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
@@ -188,7 +210,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
|||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
@@ -199,13 +220,6 @@ github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 h1:zjNCuOOhh1T
|
|||||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928/go.mod h1:PCFYfAEfKT+Nd6zWvUpsXduMR1bXFLf0uGSlEF05MCI=
|
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928/go.mod h1:PCFYfAEfKT+Nd6zWvUpsXduMR1bXFLf0uGSlEF05MCI=
|
||||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
|
||||||
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||||
@@ -214,16 +228,22 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
|||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
|
||||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
|
||||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
|
||||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
|
||||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
|
||||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||||
|
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||||
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
|
||||||
|
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
@@ -231,18 +251,18 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -251,21 +271,25 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
|
||||||
|
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
@@ -277,5 +301,5 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
|||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||||
tags.cncf.io/container-device-interface v1.1.0 h1:RnxNhxF1JOu6CJUVpetTYvrXHdxw9j9jFYgZpI+anSY=
|
tags.cncf.io/container-device-interface v1.1.1 h1:YPwQz4xg8PlQ0yT/baR0BtLpTQROe4l6M1yuRgAu1vc=
|
||||||
tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q=
|
tags.cncf.io/container-device-interface v1.1.1/go.mod h1:S1PSJWYPD4Iom0/39mvr/VVFCfG0Yt14j20d0OYrY2M=
|
||||||
@@ -6,8 +6,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/artifactcache"
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
"gitea.com/gitea/runner/internal/app/run"
|
"gitea.com/gitea/runner/internal/app/run"
|
||||||
@@ -31,6 +29,9 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
|
|||||||
}
|
}
|
||||||
|
|
||||||
initLogging(cfg)
|
initLogging(cfg)
|
||||||
|
if cfg.Cache.S3 != nil {
|
||||||
|
return errors.New("cache-server does not support cache.s3; set cache.s3 on the runners instead")
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
dir = cfg.Cache.Dir
|
dir = cfg.Cache.Dir
|
||||||
@@ -67,10 +68,8 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
|
|||||||
|
|
||||||
log.Infof("cache server is listening on %v", cacheHandler.ExternalURL())
|
log.Infof("cache server is listening on %v", cacheHandler.ExternalURL())
|
||||||
|
|
||||||
c := make(chan os.Signal, 1)
|
<-cmd.Context().Done()
|
||||||
signal.Notify(c, os.Interrupt)
|
|
||||||
<-c
|
|
||||||
|
|
||||||
return nil
|
return cacheHandler.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ func Execute(ctx context.Context) {
|
|||||||
// hide completion command
|
// hide completion command
|
||||||
rootCmd.CompletionOptions.HiddenDefaultCmd = true
|
rootCmd.CompletionOptions.HiddenDefaultCmd = true
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.ExecuteContext(ctx); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||||
"gitea.com/gitea/runner/internal/pkg/report"
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/telemetry"
|
||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
@@ -71,6 +72,18 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
defer func() { _ = releaseLock() }()
|
defer func() { _ = releaseLock() }()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shutdownTelemetry, err := telemetry.Setup(ctx, reg.UUID, reg.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Warn("OpenTelemetry export failed to start")
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := shutdownTelemetry(ctx); err != nil {
|
||||||
|
log.WithError(err).Warn("OpenTelemetry export failed to flush")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels)
|
lbls := resolveLabels(daemArgs.Labels, cfg.Runner.Labels, reg.Labels)
|
||||||
|
|
||||||
ls := labels.Labels{}
|
ls := labels.Labels{}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -187,7 +188,10 @@ func (i *executeArgs) LoadEnvs() map[string]string {
|
|||||||
envs := parseKVAndFile(i.envs, i.Envfile())
|
envs := parseKVAndFile(i.envs, i.Envfile())
|
||||||
|
|
||||||
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
||||||
// The same server answers the cache service v2 API, so let the actions reach it.
|
// The same server answers cache v2, which docker buildx reads from the results origin alone.
|
||||||
|
if envs["ACTIONS_RESULTS_URL"] == "" {
|
||||||
|
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), i.cacheHandler.ExternalURL())
|
||||||
|
}
|
||||||
envs[runner.CacheServiceV2Env] = "true"
|
envs[runner.CacheServiceV2Env] = "true"
|
||||||
|
|
||||||
return envs
|
return envs
|
||||||
@@ -540,6 +544,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.Env["ACT_EXEC"] = "true"
|
config.Env["ACT_EXEC"] = "true"
|
||||||
|
config.Secrets[actionsRuntimeTokenEnvName] = actionsRuntimeToken
|
||||||
|
|
||||||
if t := config.Secrets["GITEA_TOKEN"]; t != "" {
|
if t := config.Secrets["GITEA_TOKEN"]; t != "" {
|
||||||
config.Token = t
|
config.Token = t
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
|
"gitea.com/gitea/runner/act/runner"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
@@ -307,3 +310,19 @@ func captureStdout(t *testing.T, fn func()) string {
|
|||||||
require.NoError(t, r.Close())
|
require.NoError(t, r.Close())
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) {
|
||||||
|
t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does
|
||||||
|
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = handler.Close() })
|
||||||
|
args := &executeArgs{cacheHandler: handler}
|
||||||
|
|
||||||
|
envs := args.LoadEnvs()
|
||||||
|
assert.Equal(t, handler.ExternalURL(), envs["ACTIONS_RESULTS_URL"],
|
||||||
|
"the v2 flag is advertised, so something has to serve that origin")
|
||||||
|
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
|
||||||
|
|
||||||
|
args.envs = []string{"ACTIONS_RESULTS_URL=https://gitea.example"}
|
||||||
|
assert.Equal(t, "https://gitea.example", args.LoadEnvs()["ACTIONS_RESULTS_URL"], "a supplied origin wins")
|
||||||
|
}
|
||||||
+185
-109
@@ -5,12 +5,16 @@ package run
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json/v2"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -32,6 +36,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||||
"gitea.com/gitea/runner/internal/pkg/report"
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/telemetry"
|
||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
@@ -39,6 +44,7 @@ import (
|
|||||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CapabilityCancelling tells the server this runner understands the
|
// CapabilityCancelling tells the server this runner understands the
|
||||||
@@ -66,6 +72,8 @@ type Runner struct {
|
|||||||
cacheHandler *artifactcache.Handler
|
cacheHandler *artifactcache.Handler
|
||||||
capabilities string
|
capabilities string
|
||||||
|
|
||||||
|
isolatedCacheContainer func() string
|
||||||
|
|
||||||
runningTasks sync.Map
|
runningTasks sync.Map
|
||||||
runningCount atomic.Int64
|
runningCount atomic.Int64
|
||||||
lastIdleCleanupUnixNano atomic.Int64
|
lastIdleCleanupUnixNano atomic.Int64
|
||||||
@@ -89,27 +97,24 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
envs := make(map[string]string, len(cfg.Runner.Envs))
|
envs := make(map[string]string, len(cfg.Runner.Envs))
|
||||||
maps.Copy(envs, cfg.Runner.Envs)
|
maps.Copy(envs, cfg.Runner.Envs)
|
||||||
var cacheHandler *artifactcache.Handler
|
var cacheHandler *artifactcache.Handler
|
||||||
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
if cacheEnabled(cfg) {
|
||||||
if cfg.Cache.ExternalServer != "" {
|
warnIgnoredCachePolicy(cfg)
|
||||||
warnIgnoredCachePolicy(cfg)
|
warnIgnoredCacheSecret(cfg)
|
||||||
// The v1 client appends its path to this without a separator, so the slash is required.
|
handler, err := artifactcache.StartHandler(artifactcache.Options{
|
||||||
envs["ACTIONS_CACHE_URL"] = strings.TrimRight(cfg.Cache.ExternalServer, "/") + "/"
|
Dir: cfg.Cache.Dir,
|
||||||
|
OutboundIP: cfg.Cache.Host,
|
||||||
|
Port: cfg.Cache.Port,
|
||||||
|
Upstream: cfg.Cache.ExternalServer,
|
||||||
|
S3: cacheS3(cfg),
|
||||||
|
Policy: CachePolicy(cfg),
|
||||||
|
Logger: log.StandardLogger().WithField("module", "cache_request"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("cannot init cache server, it will be disabled: %v", err)
|
||||||
|
// go on
|
||||||
} else {
|
} else {
|
||||||
warnIgnoredCacheSecret(cfg)
|
cacheHandler = handler
|
||||||
handler, err := artifactcache.StartHandler(artifactcache.Options{
|
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
|
||||||
Dir: cfg.Cache.Dir,
|
|
||||||
OutboundIP: cfg.Cache.Host,
|
|
||||||
Port: cfg.Cache.Port,
|
|
||||||
Policy: CachePolicy(cfg),
|
|
||||||
Logger: log.StandardLogger().WithField("module", "cache_request"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("cannot init cache server, it will be disabled: %v", err)
|
|
||||||
// go on
|
|
||||||
} else {
|
|
||||||
cacheHandler = handler
|
|
||||||
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +138,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
now: time.Now,
|
now: time.Now,
|
||||||
runHealthCheck: executeHealthCheck,
|
runHealthCheck: executeHealthCheck,
|
||||||
}
|
}
|
||||||
|
runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer)
|
||||||
return runner
|
return runner
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +150,8 @@ func (r *Runner) Close() error {
|
|||||||
// removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon.
|
// removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon.
|
||||||
var removeOrphanNetworks = container.RemoveOrphanNetworks
|
var removeOrphanNetworks = container.RemoveOrphanNetworks
|
||||||
|
|
||||||
|
var removeOrphanJobVolumes = container.RemoveOrphanJobVolumes
|
||||||
|
|
||||||
// OnIdle performs lightweight maintenance during polling idle windows.
|
// OnIdle performs lightweight maintenance during polling idle windows.
|
||||||
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
|
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
|
||||||
// throttles invocations to runner.idle_cleanup_interval so the impact on
|
// throttles invocations to runner.idle_cleanup_interval so the impact on
|
||||||
@@ -165,21 +173,20 @@ func (r *Runner) OnIdle(ctx context.Context) {
|
|||||||
if hostRoot := filepath.FromSlash(r.cfg.Host.WorkdirParent); hostRoot != "" {
|
if hostRoot := filepath.FromSlash(r.cfg.Host.WorkdirParent); hostRoot != "" {
|
||||||
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
|
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
|
||||||
}
|
}
|
||||||
r.cleanupOrphanNetworks(ctx)
|
r.cleanupOrphanDockerResources(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupOrphanNetworks reclaims the per-job networks of jobs this runner did not live to
|
func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
|
||||||
// tear down. A labelled network with no containers on it is finished with, and as for the
|
if r.uuid == "" || (!r.requiresDocker() && !dockerReachable(ctx)) {
|
||||||
// directories above, a task beginning during the pass is safe because the cutoff keeps a
|
|
||||||
// network it has created but not yet attached a container to out of scope.
|
|
||||||
func (r *Runner) cleanupOrphanNetworks(ctx context.Context) {
|
|
||||||
if r.uuid == "" || !r.requiresDocker() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
|
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
|
||||||
if err := removeOrphanNetworks(ctx, r.uuid, cutoff); err != nil {
|
if err := removeOrphanNetworks(ctx, r.uuid, r.isolatedCacheContainer(), cutoff); err != nil {
|
||||||
log.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
|
log.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
|
||||||
|
log.Warnf("failed to clean up volumes left behind by earlier jobs: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) shouldRunIdleCleanup() bool {
|
func (r *Runner) shouldRunIdleCleanup() bool {
|
||||||
@@ -279,32 +286,44 @@ func (r *Runner) SetCapabilitiesFromDeclare(resp *connect.Response[runnerv1.Decl
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
||||||
if _, ok := r.runningTasks.Load(task.Id); ok {
|
if _, ok := r.runningTasks.LoadOrStore(task.Id, struct{}{}); ok {
|
||||||
return fmt.Errorf("task %d is already running", task.Id)
|
return fmt.Errorf("task %d is already running", task.Id)
|
||||||
}
|
}
|
||||||
r.runningTasks.Store(task.Id, struct{}{})
|
|
||||||
defer r.runningTasks.Delete(task.Id)
|
defer r.runningTasks.Delete(task.Id)
|
||||||
|
|
||||||
r.runningCount.Add(1)
|
r.runningCount.Add(1)
|
||||||
|
defer r.runningCount.Add(-1)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, r.cfg.Runner.Timeout)
|
ctx, cancel := context.WithTimeout(ctx, r.cfg.Runner.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
ctx, endJob := telemetry.StartJob(ctx, task)
|
||||||
// A proxy URL may carry credentials, and every job is given it; keep them out of the log.
|
// A proxy URL may carry credentials, and every job is given it; keep them out of the log.
|
||||||
reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg, proxyPasswords()...)
|
reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg, proxyPasswords()...)
|
||||||
|
var volumeCleanup []common.Executor
|
||||||
|
var volumeCleanupMu sync.Mutex
|
||||||
|
if r.cfg.Runner.PostTaskScript == "" {
|
||||||
|
ctx = runner.WithJobVolumeCleanup(ctx, func(cleanup common.Executor) {
|
||||||
|
volumeCleanupMu.Lock()
|
||||||
|
defer volumeCleanupMu.Unlock()
|
||||||
|
volumeCleanup = append(volumeCleanup, cleanup)
|
||||||
|
})
|
||||||
|
}
|
||||||
var runErr error
|
var runErr error
|
||||||
defer func() {
|
defer func() {
|
||||||
r.runningCount.Add(-1)
|
|
||||||
|
|
||||||
lastWords := ""
|
lastWords := ""
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
lastWords = runErr.Error()
|
lastWords = runErr.Error()
|
||||||
}
|
}
|
||||||
_ = reporter.Close(lastWords)
|
_ = reporter.Close(lastWords)
|
||||||
|
if err := cleanupJobVolumes(ctx, volumeCleanup); err != nil {
|
||||||
|
log.Warnf("task %d volume cleanup after reporting: %v", task.Id, err)
|
||||||
|
}
|
||||||
|
|
||||||
metrics.JobDuration.Observe(time.Since(start).Seconds())
|
metrics.JobDuration.Observe(time.Since(start).Seconds())
|
||||||
metrics.JobsTotal.WithLabelValues(metrics.ResultToStatusLabel(reporter.Result())).Inc()
|
metrics.JobsTotal.WithLabelValues(metrics.ResultToStatusLabel(reporter.Result())).Inc()
|
||||||
|
endJob(reporter.Result())
|
||||||
}()
|
}()
|
||||||
reporter.RunDaemon()
|
reporter.RunDaemon()
|
||||||
runErr = r.run(ctx, task, reporter)
|
runErr = r.run(ctx, task, reporter)
|
||||||
@@ -312,6 +331,16 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupJobVolumes(ctx context.Context, cleanups []common.Executor) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
var errs []error
|
||||||
|
for _, cleanup := range cleanups {
|
||||||
|
errs = append(errs, cleanup(ctx))
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) cloneEnvs() map[string]string {
|
func (r *Runner) cloneEnvs() map[string]string {
|
||||||
// Reserve space for the per-task keys injected by run():
|
// Reserve space for the per-task keys injected by run():
|
||||||
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN,
|
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN,
|
||||||
@@ -382,13 +411,29 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
}
|
}
|
||||||
job := workflow.GetJob(jobID)
|
job := workflow.GetJob(jobID)
|
||||||
reporter.ResetSteps(len(job.Steps))
|
reporter.ResetSteps(len(job.Steps))
|
||||||
|
telemetry.SetJobName(ctx, cmp.Or(job.Name, jobID))
|
||||||
|
|
||||||
taskContext := task.Context.Fields
|
taskContext := task.Context.Fields
|
||||||
envs := r.cloneEnvs()
|
envs := r.cloneEnvs()
|
||||||
|
|
||||||
|
// act asks for the platform once per step, so resolve the fallback at most once per task.
|
||||||
|
fallbackPlatform := sync.OnceValue(func() string { return r.fallbackPlatform(ctx) })
|
||||||
|
platformPicker := func(runsOn []string) string {
|
||||||
|
if platform := r.labels.PickPlatform(runsOn); platform != "" {
|
||||||
|
return platform
|
||||||
|
}
|
||||||
|
return fallbackPlatform()
|
||||||
|
}
|
||||||
|
cacheURL, cacheContainer := r.cacheForJob(job, platformPicker)
|
||||||
|
if cacheURL != "" {
|
||||||
|
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
|
||||||
|
}
|
||||||
|
|
||||||
// Added per task because this job's service containers must be reached directly, and
|
// Added per task because this job's service containers must be reached directly, and
|
||||||
// act reaches them by their workflow key.
|
// act reaches them by their workflow key.
|
||||||
proxyEnv := JobProxyEnv(envs, envs["ACTIONS_CACHE_URL"], slices.Sorted(maps.Keys(job.Services)))
|
var services map[string]yaml.Node
|
||||||
|
_ = job.RawServices.Decode(&services) // a whole-value expression names no service before evaluation
|
||||||
|
proxyEnv := JobProxyEnv(envs, cacheURL, slices.Sorted(maps.Keys(services)))
|
||||||
maps.Copy(envs, proxyEnv)
|
maps.Copy(envs, proxyEnv)
|
||||||
|
|
||||||
if r.capabilities != "" {
|
if r.capabilities != "" {
|
||||||
@@ -397,29 +442,13 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
if v := taskContext["run_id"].GetStringValue(); v != "" {
|
if v := taskContext["run_id"].GetStringValue(); v != "" {
|
||||||
envs["GITEA_RUN_ID"] = v
|
envs["GITEA_RUN_ID"] = v
|
||||||
}
|
}
|
||||||
|
telemetry.AddJobEnv(ctx, envs)
|
||||||
|
|
||||||
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(),
|
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(),
|
||||||
r.getDefaultActionsURL(task),
|
r.getDefaultActionsURL(task),
|
||||||
r.client.Address())
|
r.client.Address())
|
||||||
|
|
||||||
preset := &model.GithubContext{
|
preset := model.GithubContextFromMap(task.Context.AsMap())
|
||||||
Event: taskContext["event"].GetStructValue().AsMap(),
|
|
||||||
RunID: taskContext["run_id"].GetStringValue(),
|
|
||||||
RunNumber: taskContext["run_number"].GetStringValue(),
|
|
||||||
RunAttempt: taskContext["run_attempt"].GetStringValue(),
|
|
||||||
Actor: taskContext["actor"].GetStringValue(),
|
|
||||||
Repository: taskContext["repository"].GetStringValue(),
|
|
||||||
EventName: taskContext["event_name"].GetStringValue(),
|
|
||||||
Sha: taskContext["sha"].GetStringValue(),
|
|
||||||
Ref: taskContext["ref"].GetStringValue(),
|
|
||||||
RefName: taskContext["ref_name"].GetStringValue(),
|
|
||||||
RefType: taskContext["ref_type"].GetStringValue(),
|
|
||||||
HeadRef: taskContext["head_ref"].GetStringValue(),
|
|
||||||
BaseRef: taskContext["base_ref"].GetStringValue(),
|
|
||||||
Token: taskContext["token"].GetStringValue(),
|
|
||||||
RepositoryOwner: taskContext["repository_owner"].GetStringValue(),
|
|
||||||
RetentionDays: taskContext["retention_days"].GetStringValue(),
|
|
||||||
}
|
|
||||||
if t := task.Secrets["GITEA_TOKEN"]; t != "" {
|
if t := task.Secrets["GITEA_TOKEN"]; t != "" {
|
||||||
preset.Token = t
|
preset.Token = t
|
||||||
} else if t := task.Secrets["GITHUB_TOKEN"]; t != "" {
|
} else if t := task.Secrets["GITHUB_TOKEN"]; t != "" {
|
||||||
@@ -449,19 +478,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
// cache requests from the job container can authenticate. The credential
|
// cache requests from the job container can authenticate. The credential
|
||||||
// is removed when the task finishes, so a leaked token stops working as
|
// is removed when the task finishes, so a leaked token stops working as
|
||||||
// soon as the job ends rather than remaining valid for the runner's
|
// soon as the job ends rather than remaining valid for the runner's
|
||||||
// lifetime. Only applies to the embedded cache server; when the operator
|
// lifetime.
|
||||||
// points the runner at an external cache via cfg.Cache.ExternalServer, it
|
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, cacheURL, reporter)
|
||||||
// is that server's responsibility to authenticate requests.
|
|
||||||
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)
|
|
||||||
defer revokeCache()
|
defer revokeCache()
|
||||||
// A cache server that agreed to forward the artifact half is the whole results service, so the
|
|
||||||
// job is pointed at it.
|
|
||||||
if resultsURL != "" {
|
|
||||||
envs["ACTIONS_RESULTS_URL"] = resultsURL
|
|
||||||
if r.cacheServiceV2() {
|
|
||||||
envs[runner.CacheServiceV2Env] = "true"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eventJSON, err := json.Marshal(preset.Event)
|
eventJSON, err := json.Marshal(preset.Event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -493,14 +512,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
// Without bind_workdir, the workspace path omits the task id; concurrent host-mode jobs
|
// Without bind_workdir, the workspace path omits the task id; concurrent host-mode jobs
|
||||||
// for the same repository would share this directory and can race with per-job cleanup.
|
// for the same repository would share this directory and can race with per-job cleanup.
|
||||||
|
|
||||||
// act asks for the platform once per step, so resolve the fallback at most once per task.
|
r.setResultsService(envs, resultsURL)
|
||||||
fallbackPlatform := sync.OnceValue(func() string { return r.fallbackPlatform(ctx) })
|
|
||||||
platformPicker := func(runsOn []string) string {
|
|
||||||
if platform := r.labels.PickPlatform(runsOn); platform != "" {
|
|
||||||
return platform
|
|
||||||
}
|
|
||||||
return fallbackPlatform()
|
|
||||||
}
|
|
||||||
|
|
||||||
runnerConfig := &runner.Config{
|
runnerConfig := &runner.Config{
|
||||||
// On Linux, Workdir will be like "/<parent_directory>/<owner>/<repo>"
|
// On Linux, Workdir will be like "/<parent_directory>/<owner>/<repo>"
|
||||||
@@ -529,6 +541,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
ContainerMaxLifetime: maxLifetime,
|
ContainerMaxLifetime: maxLifetime,
|
||||||
CleanWorkdir: true,
|
CleanWorkdir: true,
|
||||||
ContainerNetworkMode: docker_container.NetworkMode(r.cfg.Container.Network),
|
ContainerNetworkMode: docker_container.NetworkMode(r.cfg.Container.Network),
|
||||||
|
CacheContainer: cacheContainer,
|
||||||
ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{
|
ContainerNetworkCreateOptions: container.NewDockerNetworkCreateExecutorInput{
|
||||||
EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4,
|
EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4,
|
||||||
EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6,
|
EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6,
|
||||||
@@ -584,43 +597,66 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
return execErr
|
return execErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) builtInCacheURL() string {
|
||||||
|
if r.cacheHandler == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.envs["ACTIONS_CACHE_URL"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheEnabled(cfg *config.Config) bool {
|
||||||
|
return cfg.Cache.Enabled == nil || *cfg.Cache.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
// cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2
|
// cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2
|
||||||
// turns off: the bundle edit that reaches it is what the artifact actions need too.
|
// turns off: the bundle edit that reaches it is what the artifact actions need too.
|
||||||
func (r *Runner) cacheServiceV2() bool {
|
func (r *Runner) cacheServiceV2() bool {
|
||||||
return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2
|
return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
|
||||||
|
v2 := resultsURL != "" && r.cacheServiceV2()
|
||||||
|
if v2 {
|
||||||
|
envs[runner.CacheServiceV2Env] = "true"
|
||||||
|
} else {
|
||||||
|
delete(envs, runner.CacheServiceV2Env)
|
||||||
|
}
|
||||||
|
// Cache v2 clients read no other variable for it, and some cannot use the instance address.
|
||||||
|
if v2 || (resultsURL != "" && r.instanceOutOfReach(envs["ACTIONS_RESULTS_URL"])) {
|
||||||
|
envs["ACTIONS_RESULTS_URL"] = resultsURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These clients keep only the origin they are handed, and reach it on the job's own trust.
|
||||||
|
func (r *Runner) instanceOutOfReach(instance string) bool {
|
||||||
|
parsed, err := url.Parse(instance)
|
||||||
|
return err != nil || strings.Trim(parsed.Path, "/") != "" ||
|
||||||
|
(r.cfg.Runner.Insecure && parsed.Scheme == "https")
|
||||||
|
}
|
||||||
|
|
||||||
// registerCacheForTask tells the cache server to accept requests authenticated
|
// registerCacheForTask tells the cache server to accept requests authenticated
|
||||||
// with the given runtime token for the duration of this task. Returns a
|
// with the given runtime token for the duration of this task. Returns a
|
||||||
// function the caller must invoke (typically via defer) to revoke the
|
// function the caller must invoke (typically via defer) to revoke the
|
||||||
// credential when the task finishes.
|
// credential when the task finishes.
|
||||||
//
|
//
|
||||||
// Two modes:
|
|
||||||
// - Embedded handler: register in-process via RegisterJob.
|
|
||||||
// - external_server: POST to the remote server's /_internal/register, defer a
|
|
||||||
// POST to /_internal/revoke. This is what enables full per-job auth and
|
|
||||||
// repo scoping over the network.
|
|
||||||
//
|
|
||||||
// Safe with an empty token (older Gitea did not issue one).
|
// Safe with an empty token (older Gitea did not issue one).
|
||||||
// It also returns the URL to advertise as ACTIONS_RESULTS_URL, which is the cache server itself
|
// It also returns the URL to advertise as ACTIONS_RESULTS_URL.
|
||||||
// when it agreed to forward this instance's artifact service, and "" when it did not.
|
func (r *Runner) registerCacheForTask(token, repo, publicURL string, reporter *report.Reporter) (func(), string) {
|
||||||
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) (func(), string) {
|
if token == "" || r.cacheHandler == nil {
|
||||||
if token == "" {
|
|
||||||
return func() {}, ""
|
return func() {}, ""
|
||||||
}
|
}
|
||||||
cred := artifactcache.JobCredential{
|
cred := artifactcache.JobCredential{
|
||||||
Repo: repo,
|
Repo: repo,
|
||||||
|
PublicURL: publicURL,
|
||||||
Results: r.envs["ACTIONS_RESULTS_URL"], // the instance as the job would reach it
|
Results: r.envs["ACTIONS_RESULTS_URL"], // the instance as the job would reach it
|
||||||
InsecureTLS: r.cfg.Runner.Insecure,
|
InsecureTLS: r.cfg.Runner.Insecure,
|
||||||
}
|
}
|
||||||
if r.cacheHandler != nil {
|
revoke := r.cacheHandler.RegisterJob(token, cred)
|
||||||
return r.cacheHandler.RegisterJob(token, cred), r.cacheHandler.ResultsURL(cred)
|
if r.cfg.Cache.ExternalServer != "" {
|
||||||
|
revokeLocal, revokeExternal := revoke, r.registerExternalCacheJob(token, repo, publicURL, reporter)
|
||||||
|
revoke = func() { revokeLocal(); revokeExternal() }
|
||||||
}
|
}
|
||||||
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
return revoke, r.cacheHandler.ResultsURL(cred)
|
||||||
return r.registerExternalCacheJob(token, cred, reporter)
|
|
||||||
}
|
|
||||||
// No cache server to register against: caching is disabled, or the built-in server failed to start.
|
|
||||||
return func() {}, ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
|
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
|
||||||
@@ -628,23 +664,19 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
|
|||||||
// 401 the job's requests — better than failing the whole task for a cache
|
// 401 the job's requests — better than failing the whole task for a cache
|
||||||
// outage. The warning is mirrored to the job log so users can see why their
|
// outage. The warning is mirrored to the job log so users can see why their
|
||||||
// cache calls 401, instead of having to read the runner daemon's stderr.
|
// cache calls 401, instead of having to read the runner daemon's stderr.
|
||||||
func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) {
|
func (r *Runner) registerExternalCacheJob(token, repo, publicURL string, reporter *report.Reporter) func() {
|
||||||
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
|
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
|
||||||
resultsURL := ""
|
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, map[string]any{
|
||||||
if body, err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, map[string]any{
|
"token": token, "repo": repo, "public_url": publicURL,
|
||||||
"token": token, "repo": cred.Repo, "results": cred.Results, "insecure_tls": cred.InsecureTLS,
|
|
||||||
"public_url": base,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Warnf("cache external_server register failed (%s): %v", base, err)
|
log.Warnf("cache external_server register failed (%s): %v", base, err)
|
||||||
if reporter != nil {
|
if reporter != nil {
|
||||||
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf(
|
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf(
|
||||||
"cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)))
|
"cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)))
|
||||||
}
|
}
|
||||||
} else if forwarded, _ := body["results_url"].(string); forwarded != "" {
|
|
||||||
resultsURL = base // the answer only says it forwards, its own address need not be the job's
|
|
||||||
}
|
}
|
||||||
return func() {
|
return func() {
|
||||||
if _, err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
|
if err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
|
||||||
map[string]any{"token": token}); err != nil {
|
map[string]any{"token": token}); err != nil {
|
||||||
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
|
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
|
||||||
if reporter != nil {
|
if reporter != nil {
|
||||||
@@ -652,33 +684,30 @@ func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCr
|
|||||||
"cache external_server revoke failed (%s): %v", base, err)))
|
"cache external_server revoke failed (%s): %v", base, err)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, resultsURL
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func postInternalCache(url, secret string, body map[string]any) (map[string]any, error) {
|
func postInternalCache(url, secret string, body map[string]any) error {
|
||||||
buf, err := json.Marshal(body)
|
buf, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+secret)
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode/100 != 2 {
|
if resp.StatusCode/100 != 2 {
|
||||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
return fmt.Errorf("status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
answer := map[string]any{}
|
return nil
|
||||||
// A server too old to answer with a body is not an error, it simply tells us nothing.
|
|
||||||
_ = json.UnmarshalRead(resp.Body, &answer)
|
|
||||||
return answer, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) RunningCount() int64 {
|
func (r *Runner) RunningCount() int64 {
|
||||||
@@ -774,12 +803,27 @@ func CachePolicy(cfg *config.Config) artifactcache.Policy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// warnIgnoredCachePolicy flags eviction settings configured on a runner that points at an external cache server.
|
func cacheS3(cfg *config.Config) *artifactcache.S3Options {
|
||||||
|
s3 := cfg.Cache.S3
|
||||||
|
if s3 == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &artifactcache.S3Options{
|
||||||
|
Endpoint: s3.Endpoint, Region: s3.Region, Bucket: s3.Bucket, Prefix: s3.Prefix,
|
||||||
|
PathStyle: s3.PathStyle, AccessKey: s3.AccessKey, SecretKey: s3.SecretKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnIgnoredCachePolicy flags eviction settings configured on a runner that does not apply them.
|
||||||
func warnIgnoredCachePolicy(cfg *config.Config) {
|
func warnIgnoredCachePolicy(cfg *config.Config) {
|
||||||
defaults := config.DefaultCache()
|
defaults := config.DefaultCache()
|
||||||
if cfg.Cache.Retention != defaults.Retention || cfg.Cache.RepoSizeLimit != defaults.RepoSizeLimit ||
|
limits := cfg.Cache.Retention != defaults.Retention || cfg.Cache.RepoSizeLimit != defaults.RepoSizeLimit ||
|
||||||
cfg.Cache.SizeLimit != defaults.SizeLimit || cfg.Cache.SweepInterval != defaults.SweepInterval {
|
cfg.Cache.SizeLimit != defaults.SizeLimit
|
||||||
|
switch {
|
||||||
|
case cfg.Cache.ExternalServer != "" && (limits || cfg.Cache.SweepInterval != defaults.SweepInterval):
|
||||||
log.Warn("cache eviction settings are ignored when cache.external_server is set; configure them on that server instead")
|
log.Warn("cache eviction settings are ignored when cache.external_server is set; configure them on that server instead")
|
||||||
|
case cfg.Cache.S3 != nil && limits:
|
||||||
|
log.Warn("cache eviction settings are ignored when cache.s3 is set; use lifecycle rules on the bucket instead")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,3 +843,35 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
|
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cacheForJob returns the cache address for the job, and the container to attach to its network to reach it there.
|
||||||
|
func (r *Runner) cacheForJob(job *model.Job, pickPlatform func([]string) string) (string, string) {
|
||||||
|
jobContainer := job.Container()
|
||||||
|
cacheContainer := ""
|
||||||
|
if jobContainer != nil && jobContainer.Image != "" || pickPlatform(job.RunsOn()) != labels.SelfHostedPlatform {
|
||||||
|
cacheContainer = r.isolatedCacheContainer()
|
||||||
|
}
|
||||||
|
if cacheContainer == "" {
|
||||||
|
return strings.TrimSuffix(r.builtInCacheURL(), "/"), ""
|
||||||
|
}
|
||||||
|
cacheURL, _ := url.Parse(r.cacheHandler.ExternalURL())
|
||||||
|
cacheURL.Host = net.JoinHostPort(cacheContainer, cacheURL.Port())
|
||||||
|
return cacheURL.String(), cacheContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) detectIsolatedCacheContainer() string {
|
||||||
|
if r.cacheHandler == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(hostOf(r.cacheHandler.ExternalURL()))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("cannot check whether jobs reach the cache server: %v", err)
|
||||||
|
}
|
||||||
|
return cacheContainer
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/artifactcache"
|
"gitea.com/gitea/runner/act/artifactcache"
|
||||||
|
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -23,54 +24,13 @@ import (
|
|||||||
|
|
||||||
func emptyCfg() *config.Config { return &config.Config{} }
|
func emptyCfg() *config.Config { return &config.Config{} }
|
||||||
|
|
||||||
func TestRunner_registerCacheForTask(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
|
||||||
token := "run-token-123"
|
|
||||||
unregister, _ := r.registerCacheForTask(token, "owner/repo", nil)
|
|
||||||
|
|
||||||
base := handler.ExternalURL() + "/_apis/artifactcache"
|
|
||||||
probe := func() int {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
resp.Body.Close()
|
|
||||||
return resp.StatusCode
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NotEqual(t, http.StatusUnauthorized, probe(),
|
|
||||||
"token should be accepted while task is registered")
|
|
||||||
|
|
||||||
unregister()
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, probe(),
|
|
||||||
"token must be rejected after the revoker runs")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
||||||
t.Run("nil cacheHandler", func(t *testing.T) {
|
for token, handler := range map[string]*artifactcache.Handler{"tok": nil, "": {}} {
|
||||||
r := &Runner{cfg: emptyCfg()}
|
|
||||||
unregister, _ := r.registerCacheForTask("tok", "owner/repo", nil)
|
|
||||||
require.NotNil(t, unregister)
|
|
||||||
unregister()
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty token", func(t *testing.T) {
|
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
|
||||||
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
|
|
||||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
||||||
unregister, _ := r.registerCacheForTask("", "owner/repo", nil)
|
unregister, _ := r.registerCacheForTask(token, "owner/repo", "", nil)
|
||||||
require.NotNil(t, unregister)
|
require.NotNil(t, unregister, token)
|
||||||
unregister()
|
unregister()
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit
|
// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit
|
||||||
@@ -81,10 +41,11 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer handler.Close()
|
defer handler.Close()
|
||||||
|
|
||||||
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
|
r := &Runner{cfg: emptyCfg(), cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
|
||||||
|
const publicURL = "http://a1b2c3d4e5f6:8088"
|
||||||
token := "full-flow-token"
|
token := "full-flow-token"
|
||||||
unregister, _ := r.registerCacheForTask(token, "owner/repo", nil)
|
unregister, resultsURL := r.registerCacheForTask(token, "owner/repo", publicURL, nil)
|
||||||
defer unregister()
|
assert.Equal(t, publicURL, resultsURL)
|
||||||
|
|
||||||
base := handler.ExternalURL() + "/_apis/artifactcache"
|
base := handler.ExternalURL() + "/_apis/artifactcache"
|
||||||
do := func(method, url, contentType, contentRange, body string) *http.Response {
|
do := func(method, url, contentType, contentRange, body string) *http.Response {
|
||||||
@@ -137,17 +98,22 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, decodeJSON(resp, &hit))
|
require.NoError(t, decodeJSON(resp, &hit))
|
||||||
require.Equal(t, key, hit.CacheKey)
|
require.Equal(t, key, hit.CacheKey)
|
||||||
require.NotEmpty(t, hit.ArchiveLocation)
|
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
|
||||||
|
|
||||||
// download — toolkit does NOT attach Authorization here; the signature
|
// download — toolkit does NOT attach Authorization here; the signature
|
||||||
// in the URL must be enough.
|
// in the URL must be enough.
|
||||||
dl, err := http.Get(hit.ArchiveLocation)
|
dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, handler.ExternalURL(), 1))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer dl.Body.Close()
|
defer dl.Body.Close()
|
||||||
require.Equal(t, http.StatusOK, dl.StatusCode)
|
require.Equal(t, http.StatusOK, dl.StatusCode)
|
||||||
got := make([]byte, 64)
|
got := make([]byte, 64)
|
||||||
n, _ := dl.Body.Read(got)
|
n, _ := dl.Body.Read(got)
|
||||||
assert.Equal(t, body, string(got[:n]))
|
assert.Equal(t, body, string(got[:n]))
|
||||||
|
|
||||||
|
unregister()
|
||||||
|
resp = do(http.MethodGet, fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), "", "", "")
|
||||||
|
resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJSON(resp *http.Response, v any) error {
|
func decodeJSON(resp *http.Response, v any) error {
|
||||||
@@ -155,33 +121,38 @@ func decodeJSON(resp *http.Response, v any) error {
|
|||||||
return json.UnmarshalRead(resp.Body, v)
|
return json.UnmarshalRead(resp.Body, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// End-to-end against a remote cache-server: token unknown → 401, register →
|
// End-to-end through a runner to a remote cache-server: token unknown → 401, register →
|
||||||
// reserve/upload/commit/find/download all OK, revoke → 401 again. Registering also names the
|
// reserve/upload/commit/find/download all OK, revoke → 401 again.
|
||||||
// instance and the address its jobs reach the server at, which makes a shared cache server the
|
|
||||||
// whole results service, as the built-in one is.
|
|
||||||
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "remote-cache")
|
|
||||||
const secret = "shared-secret-for-tests"
|
const secret = "shared-secret-for-tests"
|
||||||
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.2", InternalSecret: secret}) // advertised, never dialled
|
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InternalSecret: secret})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer remote.Close()
|
defer remote.Close()
|
||||||
external := strings.Replace(remote.ExternalURL(), "127.0.0.2", "127.0.0.1", 1)
|
gitea := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
assert.Equal(t, "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", req.URL.Path)
|
||||||
|
assert.Equal(t, "Bearer external-task-token", req.Header.Get("Authorization"))
|
||||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||||
}))
|
}))
|
||||||
defer gitea.Close()
|
defer gitea.Close()
|
||||||
|
|
||||||
r := &Runner{
|
cli := clientmocks.NewClient(t)
|
||||||
cfg: &config.Config{Cache: config.Cache{
|
cli.AddressValue = gitea.URL
|
||||||
ExternalServer: external,
|
r := NewRunner(&config.Config{
|
||||||
|
Runner: config.Runner{Insecure: true},
|
||||||
|
Cache: config.Cache{
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
ExternalServer: remote.ExternalURL() + "//",
|
||||||
ExternalSecret: secret,
|
ExternalSecret: secret,
|
||||||
}},
|
},
|
||||||
envs: map[string]string{"ACTIONS_RESULTS_URL": gitea.URL},
|
}, &config.Registration{}, cli)
|
||||||
}
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
require.NotNil(t, r.cacheHandler)
|
||||||
|
require.Equal(t, r.cacheHandler.ExternalURL()+"/", r.envs["ACTIONS_CACHE_URL"])
|
||||||
|
|
||||||
|
const publicURL = "http://a1b2c3d4e5f6:8088"
|
||||||
token := "external-task-token"
|
token := "external-task-token"
|
||||||
repo := "owner/repoX"
|
repo := "owner/repoX"
|
||||||
base := external + "/_apis/artifactcache"
|
base := r.envs["ACTIONS_CACHE_URL"] + "_apis/artifactcache"
|
||||||
probe := func() int {
|
probe := func() int {
|
||||||
req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil)
|
req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
@@ -194,23 +165,24 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusUnauthorized, probe(),
|
require.Equal(t, http.StatusUnauthorized, probe(),
|
||||||
"token must be unknown to the remote server before registration")
|
"token must be unknown to the remote server before registration")
|
||||||
|
|
||||||
unregister, resultsURL := r.registerCacheForTask(token, repo, nil)
|
unregister, resultsURL := r.registerCacheForTask(token, repo, publicURL, nil)
|
||||||
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
require.NotEqual(t, http.StatusUnauthorized, probe(),
|
||||||
"token must be accepted after registerCacheForTask")
|
"token must be accepted after registerCacheForTask")
|
||||||
|
|
||||||
// The server took the results service over, so the artifact half reaches Gitea through it.
|
require.Equal(t, publicURL, resultsURL)
|
||||||
require.Equal(t, external, resultsURL)
|
|
||||||
artifact, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
artifact, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
resultsURL+"/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", nil)
|
r.cacheHandler.ExternalURL()+"/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
artifact.Header.Set("Authorization", "Bearer "+token)
|
artifact.Header.Set("Authorization", "Bearer "+token)
|
||||||
forwarded, err := http.DefaultClient.Do(artifact)
|
forwarded, err := http.DefaultClient.Do(artifact)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
forwarded.Body.Close()
|
|
||||||
require.Equal(t, http.StatusOK, forwarded.StatusCode)
|
require.Equal(t, http.StatusOK, forwarded.StatusCode)
|
||||||
|
var artifactResult map[string]bool
|
||||||
|
require.NoError(t, decodeJSON(forwarded, &artifactResult))
|
||||||
|
assert.True(t, artifactResult["ok"])
|
||||||
|
|
||||||
// Full reserve→upload→commit→find→download cycle, identical to what
|
// Full reserve→upload→commit→find→download cycle, identical to what
|
||||||
// @actions/cache does, against the remote (external) server.
|
// @actions/cache does, through the runner to the remote server.
|
||||||
body := []byte("payload-from-task")
|
body := []byte("payload-from-task")
|
||||||
reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))})
|
reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))})
|
||||||
req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody))
|
req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody))
|
||||||
@@ -249,14 +221,22 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
|||||||
ArchiveLocation string `json:"archiveLocation"`
|
ArchiveLocation string `json:"archiveLocation"`
|
||||||
}
|
}
|
||||||
require.NoError(t, decodeJSON(resp, &hit))
|
require.NoError(t, decodeJSON(resp, &hit))
|
||||||
require.True(t, strings.HasPrefix(hit.ArchiveLocation, external), hit.ArchiveLocation)
|
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
|
||||||
|
|
||||||
dl, err := http.Get(hit.ArchiveLocation)
|
dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, r.cacheHandler.ExternalURL(), 1))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer dl.Body.Close()
|
defer dl.Body.Close()
|
||||||
require.Equal(t, http.StatusOK, dl.StatusCode)
|
require.Equal(t, http.StatusOK, dl.StatusCode)
|
||||||
|
downloaded, err := io.ReadAll(dl.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, body, downloaded)
|
||||||
|
|
||||||
unregister()
|
unregister()
|
||||||
assert.Equal(t, http.StatusUnauthorized, probe(),
|
assert.Equal(t, http.StatusUnauthorized, probe(),
|
||||||
"token must be rejected after the revoker runs")
|
"token must be rejected after the revoker runs")
|
||||||
|
|
||||||
|
forwarded, err = http.DefaultClient.Do(artifact)
|
||||||
|
require.NoError(t, err)
|
||||||
|
forwarded.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, forwarded.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -311,23 +311,46 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var swept []string
|
var swept []string
|
||||||
|
var detached []string
|
||||||
|
var sweptVolumes []string
|
||||||
var sweptCutoff time.Time
|
var sweptCutoff time.Time
|
||||||
origRemoveOrphanNetworks := removeOrphanNetworks
|
origRemoveOrphanNetworks := removeOrphanNetworks
|
||||||
removeOrphanNetworks = func(_ context.Context, runnerUUID string, createdBefore time.Time) error {
|
removeOrphanNetworks = func(_ context.Context, runnerUUID, detach string, createdBefore time.Time) error {
|
||||||
swept = append(swept, runnerUUID)
|
swept = append(swept, runnerUUID)
|
||||||
|
detached = append(detached, detach)
|
||||||
sweptCutoff = createdBefore
|
sweptCutoff = createdBefore
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { removeOrphanNetworks = origRemoveOrphanNetworks })
|
t.Cleanup(func() { removeOrphanNetworks = origRemoveOrphanNetworks })
|
||||||
|
origRemoveOrphanJobVolumes := removeOrphanJobVolumes
|
||||||
|
removeOrphanJobVolumes = func(_ context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
|
sweptVolumes = append(sweptVolumes, runnerUUID)
|
||||||
|
assert.Equal(t, now.Add(-24*time.Hour), createdBefore)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { removeOrphanJobVolumes = origRemoveOrphanJobVolumes })
|
||||||
|
|
||||||
r := &Runner{uuid: "runner-1", cfg: cfg, now: func() time.Time { return now }}
|
r := &Runner{uuid: "runner-1", cfg: cfg, now: func() time.Time { return now }}
|
||||||
|
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
|
||||||
r.OnIdle(context.Background())
|
r.OnIdle(context.Background())
|
||||||
assert.Equal(t, []string{"runner-1"}, swept)
|
assert.Equal(t, []string{"runner-1"}, swept)
|
||||||
|
assert.Equal(t, swept, sweptVolumes)
|
||||||
// a network of a job starting during the pass is younger than this and so out of scope
|
// a network of a job starting during the pass is younger than this and so out of scope
|
||||||
assert.Equal(t, now.Add(-24*time.Hour), sweptCutoff)
|
assert.Equal(t, now.Add(-24*time.Hour), sweptCutoff)
|
||||||
|
|
||||||
// a host-only runner has no daemon to sweep
|
// a host-only runner has no daemon to sweep
|
||||||
|
origDockerReachable := dockerReachable
|
||||||
|
dockerReachable = func(context.Context) bool { return false }
|
||||||
|
t.Cleanup(func() { dockerReachable = origDockerReachable })
|
||||||
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
|
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
|
||||||
|
hostOnly.isolatedCacheContainer = func() string { return "" }
|
||||||
hostOnly.OnIdle(context.Background())
|
hostOnly.OnIdle(context.Background())
|
||||||
assert.Equal(t, []string{"runner-1"}, swept)
|
assert.Equal(t, []string{"runner-1"}, swept)
|
||||||
|
assert.Equal(t, swept, sweptVolumes)
|
||||||
|
|
||||||
|
dockerReachable = func(context.Context) bool { return true }
|
||||||
|
now = now.Add(time.Minute)
|
||||||
|
hostOnly.OnIdle(context.Background())
|
||||||
|
assert.Equal(t, []string{"runner-1", "runner-2"}, sweptVolumes)
|
||||||
|
assert.Equal(t, []string{"a1b2c3d4e5f6", ""}, detached)
|
||||||
}
|
}
|
||||||
+164
-18
@@ -5,10 +5,18 @@ package run
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/runner"
|
"gitea.com/gitea/runner/act/runner"
|
||||||
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
|
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
@@ -16,6 +24,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
@@ -73,6 +82,113 @@ func TestRunnerRunningCountAndNullLogger(t *testing.T) {
|
|||||||
require.NotNil(t, logger.Out)
|
require.NotNil(t, logger.Out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerReclaimsVolumesAfterReporting(t *testing.T) {
|
||||||
|
for _, mode := range []string{"deferred", "post-task script"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Cache: config.Cache{Enabled: new(false)},
|
||||||
|
Runner: config.Runner{Timeout: time.Minute, LogReportInterval: time.Minute, StateReportInterval: time.Minute},
|
||||||
|
Container: config.Container{Network: "host", DockerHost: "-"},
|
||||||
|
}
|
||||||
|
if mode == "post-task script" {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses a POSIX script")
|
||||||
|
}
|
||||||
|
cfg.Runner.PostTaskScript = filepath.Join(t.TempDir(), "post-task.sh")
|
||||||
|
require.NoError(t, os.WriteFile(cfg.Runner.PostTaskScript, []byte("#!/bin/sh\n: > \"$0.done\"\n"), 0o700))
|
||||||
|
}
|
||||||
|
cli := clientmocks.NewClient(t)
|
||||||
|
cli.AddressValue = "https://gitea.example/"
|
||||||
|
r := NewRunner(cfg, &config.Registration{UUID: "runner-1", Labels: []string{"ubuntu:docker://node:20"}}, cli)
|
||||||
|
var reported atomic.Bool
|
||||||
|
var removed atomic.Int64
|
||||||
|
cli.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
|
||||||
|
assert.False(t, reported.Load())
|
||||||
|
return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil
|
||||||
|
})
|
||||||
|
cli.On("UpdateTask", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
|
||||||
|
if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
|
||||||
|
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, req.Msg.State.Result)
|
||||||
|
assert.Equal(t, int64(1), r.RunningCount())
|
||||||
|
if mode == "post-task script" {
|
||||||
|
assert.FileExists(t, cfg.Runner.PostTaskScript+".done")
|
||||||
|
}
|
||||||
|
reported.Store(true)
|
||||||
|
}
|
||||||
|
return connect.NewResponse(&runnerv1.UpdateTaskResponse{State: req.Msg.State}), nil
|
||||||
|
})
|
||||||
|
var volumesCreated atomic.Bool
|
||||||
|
daemon := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
|
writer.Header().Set("API-Version", "1.47")
|
||||||
|
path := strings.TrimPrefix(request.URL.Path, "/v1.47")
|
||||||
|
if response, ok := map[string]string{
|
||||||
|
"/_ping": "OK",
|
||||||
|
"/info": `{"Architecture":"amd64","OSType":"linux"}`,
|
||||||
|
"/containers/json": "[]",
|
||||||
|
"/networks": "[]",
|
||||||
|
"/volumes": `{"Volumes":[]}`,
|
||||||
|
"/containers/create": `{"Id":"job-id"}`,
|
||||||
|
"/containers/job-id/json": `{"Id":"job-id","Config":{},"State":{"Status":"running"}}`,
|
||||||
|
}[path]; ok {
|
||||||
|
_, _ = io.WriteString(writer, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(path, "/images/"):
|
||||||
|
_, _ = io.WriteString(writer, `{"Id":"image-id","Config":{},"Os":"linux","Architecture":"amd64"}`)
|
||||||
|
case path == "/volumes/create":
|
||||||
|
volumesCreated.Store(true)
|
||||||
|
_, _ = io.WriteString(writer, "{}")
|
||||||
|
case strings.HasSuffix(path, "/exec"):
|
||||||
|
_, _ = io.WriteString(writer, `{"Id":"exec-id"}`)
|
||||||
|
case strings.HasPrefix(path, "/exec/"):
|
||||||
|
_, _ = io.WriteString(writer, `{"Running":false,"ExitCode":0}`)
|
||||||
|
case request.Method == http.MethodDelete || strings.HasSuffix(path, "/start") || strings.HasSuffix(path, "/kill") || strings.HasSuffix(path, "/archive"):
|
||||||
|
if request.Method == http.MethodDelete && strings.HasPrefix(path, "/volumes/") && volumesCreated.Load() {
|
||||||
|
assert.Equal(t, mode != "post-task script", reported.Load())
|
||||||
|
if mode == "post-task script" {
|
||||||
|
assert.NoFileExists(t, cfg.Runner.PostTaskScript+".done")
|
||||||
|
}
|
||||||
|
assert.Equal(t, int64(1), r.RunningCount())
|
||||||
|
removed.Add(1)
|
||||||
|
}
|
||||||
|
writer.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected Docker request: %s %s", request.Method, request.URL)
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(daemon.Close)
|
||||||
|
t.Setenv("DOCKER_HOST", daemon.URL)
|
||||||
|
require.NoError(t, r.Run(t.Context(), &runnerv1.Task{
|
||||||
|
Context: &structpb.Struct{},
|
||||||
|
WorkflowPayload: []byte("jobs:\n job:\n runs-on: ubuntu\n steps:\n - run: exit 0\n if: false\n"),
|
||||||
|
}))
|
||||||
|
assert.True(t, reported.Load())
|
||||||
|
assert.Equal(t, int64(2), removed.Load())
|
||||||
|
assert.Zero(t, r.RunningCount())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupJobVolumesJoinsErrorsAfterCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
err := cleanupJobVolumes(ctx, []common.Executor{
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
require.NoError(t, ctx.Err())
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.InDelta(t, time.Minute.Seconds(), time.Until(deadline).Seconds(), 1)
|
||||||
|
return io.EOF
|
||||||
|
},
|
||||||
|
func(context.Context) error { return io.ErrClosedPipe },
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, io.EOF)
|
||||||
|
require.ErrorIs(t, err, io.ErrClosedPipe)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
|
func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
|
||||||
cacheEnabled := false
|
cacheEnabled := false
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
@@ -135,14 +251,19 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
|
|||||||
|
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
cfg.Cache.ExternalServer = "http://cache.local:8088/"
|
cfg.Cache.ExternalServer = "http://cache.local:8088/"
|
||||||
|
cfg.Cache.Host = "runner.local"
|
||||||
reg := &config.Registration{Name: "runner"}
|
reg := &config.Registration{Name: "runner"}
|
||||||
cli := clientmocks.NewClient(t)
|
cli := clientmocks.NewClient(t)
|
||||||
cli.AddressValue = "https://gitea.example/"
|
cli.AddressValue = "https://gitea.example/"
|
||||||
|
|
||||||
r := NewRunner(cfg, reg, cli)
|
r := NewRunner(cfg, reg, cli)
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
|
||||||
require.NotContains(t, r.envs, "http_proxy")
|
require.NotContains(t, r.envs, "http_proxy")
|
||||||
require.NotContains(t, r.envs, "no_proxy")
|
require.NotContains(t, r.envs, "no_proxy")
|
||||||
|
proxyEnv := JobProxyEnv(r.envs, r.builtInCacheURL(), nil)
|
||||||
|
assert.Contains(t, proxyEnv["no_proxy"], "runner.local")
|
||||||
|
assert.NotContains(t, proxyEnv["no_proxy"], "cache.local")
|
||||||
}
|
}
|
||||||
|
|
||||||
func taskWithDefaultActionsURL(url string) *runnerv1.Task {
|
func taskWithDefaultActionsURL(url string) *runnerv1.Task {
|
||||||
@@ -172,7 +293,7 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
|||||||
|
|
||||||
// The registration is what makes it true: the cache server takes the results service over,
|
// The registration is what makes it true: the cache server takes the results service over,
|
||||||
// having been told which instance to forward the artifact half to.
|
// having been told which instance to forward the artifact half to.
|
||||||
revoke, resultsURL := r.registerCacheForTask(token, "owner/repo", nil)
|
revoke, resultsURL := r.registerCacheForTask(token, "owner/repo", r.cacheHandler.ExternalURL(), nil)
|
||||||
defer revoke()
|
defer revoke()
|
||||||
require.Equal(t, r.cacheHandler.ExternalURL(), resultsURL)
|
require.Equal(t, r.cacheHandler.ExternalURL(), resultsURL)
|
||||||
|
|
||||||
@@ -189,26 +310,51 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
|
||||||
|
assert.Equal(t, r.envs["ACTIONS_CACHE_URL"], r.builtInCacheURL(), "the address only the runner knows is bypassed for the operator")
|
||||||
|
|
||||||
|
envs := r.cloneEnvs()
|
||||||
|
r.setResultsService(envs, resultsURL)
|
||||||
|
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"])
|
||||||
|
assert.Equal(t, "true", envs[runner.CacheServiceV2Env])
|
||||||
|
|
||||||
// Turning v2 off withdraws the advertisement and nothing else.
|
|
||||||
assert.True(t, r.cacheServiceV2())
|
|
||||||
cfg.Cache.V2 = new(bool)
|
cfg.Cache.V2 = new(bool)
|
||||||
assert.False(t, r.cacheServiceV2())
|
envs = r.cloneEnvs()
|
||||||
}
|
envs[runner.CacheServiceV2Env] = "true"
|
||||||
|
r.setResultsService(envs, resultsURL)
|
||||||
|
assert.Equal(t, "https://gitea.example", envs["ACTIONS_RESULTS_URL"])
|
||||||
|
assert.Empty(t, envs[runner.CacheServiceV2Env], "a runner.envs entry would promise v2 at an origin not serving it")
|
||||||
|
|
||||||
// The v1 cache client appends its path to ACTIONS_CACHE_URL without a separator, so a configured
|
envs = r.cloneEnvs()
|
||||||
// server that is missing the slash would send it to a URL whose port swallows the path.
|
envs[runner.CacheServiceV2Env] = "true"
|
||||||
func TestNewRunnerNormalizesTheExternalCacheServer(t *testing.T) {
|
envs["ACTIONS_RESULTS_URL"] = "https://gitea.example/sub"
|
||||||
cfg := &config.Config{}
|
r.setResultsService(envs, "")
|
||||||
cfg.Cache.ExternalServer = "http://cache.local:8088//"
|
assert.Equal(t, "https://gitea.example/sub", envs["ACTIONS_RESULTS_URL"], "with no cache server there is nothing to front with")
|
||||||
cli := clientmocks.NewClient(t)
|
assert.Empty(t, envs[runner.CacheServiceV2Env])
|
||||||
cli.AddressValue = "https://gitea.example/"
|
|
||||||
|
|
||||||
r := NewRunner(cfg, &config.Registration{Name: "runner"}, cli)
|
for instance, insecure := range map[string]bool{
|
||||||
|
"https://gitea.example/sub": false,
|
||||||
|
"https://self-signed.example": true,
|
||||||
|
} {
|
||||||
|
cfg.Runner.Insecure = insecure
|
||||||
|
envs = r.cloneEnvs()
|
||||||
|
envs["ACTIONS_RESULTS_URL"] = instance
|
||||||
|
r.setResultsService(envs, resultsURL)
|
||||||
|
assert.Equal(t, resultsURL, envs["ACTIONS_RESULTS_URL"], instance)
|
||||||
|
assert.Empty(t, envs[runner.CacheServiceV2Env])
|
||||||
|
}
|
||||||
|
|
||||||
assert.Equal(t, "http://cache.local:8088/", r.envs["ACTIONS_CACHE_URL"])
|
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`))
|
||||||
// Nothing to front the results service with, so the variable stays unset and the client keeps
|
require.NoError(t, err)
|
||||||
// to v1, which reads the cache URL first.
|
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
|
||||||
assert.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
|
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
|
||||||
assert.Empty(t, r.envs[runner.CacheServiceV2Env])
|
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
|
||||||
|
cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform)
|
||||||
|
if isolated {
|
||||||
|
assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job)
|
||||||
|
assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job)
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, cacheContainer, job)
|
||||||
|
assert.Equal(t, r.cacheHandler.ExternalURL(), cacheURL, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) {
|
func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) {
|
||||||
workflow, err := model.ReadWorkflow(bytes.NewReader(task.WorkflowPayload))
|
workflow, err := model.ReadWorkflowPayload(bytes.NewReader(task.WorkflowPayload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package action holds the native actions run by `uses: builtin:<name>`.
|
||||||
|
package action
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
Container container.ExecutionsEnvironment
|
||||||
|
Github *model.GithubContext
|
||||||
|
Inputs map[string]string
|
||||||
|
HostWorkdir string
|
||||||
|
Workspace string // HostWorkdir as seen inside Container
|
||||||
|
BindWorkdir bool
|
||||||
|
NoSkipCheckout bool
|
||||||
|
UseGitIgnore bool
|
||||||
|
InsecureSkipTLS bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package checkout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/action"
|
||||||
|
)
|
||||||
|
|
||||||
|
var inputNames = []string{"repository", "ref", "token", "path", "fetch-depth"}
|
||||||
|
|
||||||
|
func Main(ctx context.Context, c *action.Context) error {
|
||||||
|
for name := range c.Inputs {
|
||||||
|
if !slices.Contains(inputNames, name) {
|
||||||
|
return fmt.Errorf("checkout: unsupported input %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst, err := workspaceDestination(c.Workspace, c.Inputs["path"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
depth, err := fetchDepth(c.Inputs["fetch-depth"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ghc := c.Github
|
||||||
|
repository := cmp.Or(c.Inputs["repository"], ghc.Repository)
|
||||||
|
ref := shortBranch(c.Inputs["ref"])
|
||||||
|
isWorkflowRepo := repository == ghc.Repository
|
||||||
|
local := isWorkflowRepo && slices.Contains([]string{"", shortBranch(ghc.Ref), ghc.Sha}, ref) && !c.NoSkipCheckout
|
||||||
|
if local && !c.BindWorkdir {
|
||||||
|
return c.Container.CopyDir(dst, c.HostWorkdir+string(filepath.Separator)+".", c.UseGitIgnore, false)(ctx)
|
||||||
|
}
|
||||||
|
if local && dst == c.Workspace {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref == "" {
|
||||||
|
ref = "HEAD"
|
||||||
|
if isWorkflowRepo {
|
||||||
|
ref = ghc.Sha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir, err := os.MkdirTemp("", "checkout-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
if err := git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
||||||
|
URL: strings.TrimSuffix(ghc.ServerURL, "/") + "/" + repository,
|
||||||
|
Ref: ref,
|
||||||
|
Dir: dir,
|
||||||
|
Token: cmp.Or(c.Inputs["token"], ghc.Token),
|
||||||
|
Depth: depth,
|
||||||
|
InsecureSkipTLS: c.InsecureSkipTLS,
|
||||||
|
})(ctx); err != nil {
|
||||||
|
return fmt.Errorf("checkout: clone %s@%s: %w", repository, ref, err)
|
||||||
|
}
|
||||||
|
return c.Container.CopyDir(dst, dir+string(filepath.Separator)+".", false, false)(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceDestination(workspace, checkoutPath string) (string, error) {
|
||||||
|
cleanPath := path.Clean(strings.TrimSpace(checkoutPath))
|
||||||
|
if path.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") {
|
||||||
|
return "", fmt.Errorf("checkout: path %q must be relative to the workspace", checkoutPath)
|
||||||
|
}
|
||||||
|
return path.Join(workspace, cleanPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchDepth(input string) (int, error) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
depth, err := strconv.Atoi(input)
|
||||||
|
if err != nil || depth < 0 {
|
||||||
|
return 0, fmt.Errorf("checkout: fetch-depth %q must be a non-negative integer", input)
|
||||||
|
}
|
||||||
|
return depth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitCloneExecutor resolves branches by short name, a full tag ref stays exact.
|
||||||
|
func shortBranch(ref string) string {
|
||||||
|
return strings.TrimPrefix(ref, "refs/heads/")
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package checkout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/action"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeContainer struct {
|
||||||
|
container.ExecutionsEnvironment
|
||||||
|
dst, src string
|
||||||
|
log []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeContainer) CopyDir(dst, src string, _, _ bool) common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
f.dst, f.src = dst, src
|
||||||
|
if out, err := exec.Command("git", "--git-dir", filepath.Join(src, ".git"), "log", "--format=%s").Output(); err == nil {
|
||||||
|
f.log = strings.Fields(string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGit(t *testing.T, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=test", "[email protected]",
|
||||||
|
"GIT_COMMITTER_NAME=test", "[email protected]",
|
||||||
|
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, string(out))
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckout(t *testing.T) {
|
||||||
|
work := t.TempDir()
|
||||||
|
runGit(t, "init", "--initial-branch=main", work)
|
||||||
|
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c1")
|
||||||
|
sha := runGit(t, "-C", work, "rev-parse", "HEAD")
|
||||||
|
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c2")
|
||||||
|
runGit(t, "-C", work, "checkout", "-b", "feature")
|
||||||
|
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "f1")
|
||||||
|
runGit(t, "-C", work, "checkout", "--orphan", "trunk")
|
||||||
|
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t1")
|
||||||
|
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t2")
|
||||||
|
runGit(t, "-C", work, "tag", "trunk", "main")
|
||||||
|
server := t.TempDir()
|
||||||
|
runGit(t, "clone", "--bare", "--branch", "main", work, filepath.Join(server, "org", "repo"))
|
||||||
|
runGit(t, "clone", "--bare", "--branch", "trunk", work, filepath.Join(server, "other", "repo"))
|
||||||
|
host := t.TempDir()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
bind, noSkip bool
|
||||||
|
inputs map[string]string
|
||||||
|
dst string
|
||||||
|
log []string
|
||||||
|
hostCopy, err bool
|
||||||
|
}{
|
||||||
|
{name: "bound workspace is skipped", bind: true},
|
||||||
|
{name: "workflow ref copies host workdir", inputs: map[string]string{"ref": "main", "path": "a/../src"}, dst: "/workspace/src", hostCopy: true},
|
||||||
|
{name: "bound path clones event sha", bind: true, inputs: map[string]string{"path": "src"}, dst: "/workspace/src", log: []string{"c1"}},
|
||||||
|
{name: "no-skip clones event sha", noSkip: true, dst: "/workspace", log: []string{"c1"}},
|
||||||
|
{name: "ref clones shallow", inputs: map[string]string{"ref": "refs/heads/feature"}, dst: "/workspace", log: []string{"f1"}},
|
||||||
|
{name: "tag ref wins over same-named branch", inputs: map[string]string{"ref": "refs/tags/trunk"}, dst: "/workspace", log: []string{"c2"}},
|
||||||
|
{name: "fetch-depth 0 clones full history", inputs: map[string]string{"ref": "feature", "fetch-depth": "0"}, dst: "/workspace", log: []string{"f1", "c2", "c1"}},
|
||||||
|
{name: "other repository defaults to its default branch", inputs: map[string]string{"repository": "other/repo"}, dst: "/workspace", log: []string{"t2"}},
|
||||||
|
{name: "unsupported input", inputs: map[string]string{"submodules": "true"}, err: true},
|
||||||
|
{name: "relative path escaping workspace", inputs: map[string]string{"path": "../x"}, err: true},
|
||||||
|
{name: "absolute path", inputs: map[string]string{"path": "/x"}, err: true},
|
||||||
|
{name: "negative fetch-depth", inputs: map[string]string{"fetch-depth": "-1"}, err: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
fc := &fakeContainer{}
|
||||||
|
err := Main(t.Context(), &action.Context{
|
||||||
|
Container: fc,
|
||||||
|
Github: &model.GithubContext{Repository: "org/repo", Ref: "refs/heads/main", Sha: sha, ServerURL: server},
|
||||||
|
Inputs: tc.inputs,
|
||||||
|
HostWorkdir: host,
|
||||||
|
Workspace: "/workspace",
|
||||||
|
BindWorkdir: tc.bind,
|
||||||
|
NoSkipCheckout: tc.noSkip,
|
||||||
|
})
|
||||||
|
if tc.err {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, fc.dst)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.dst, fc.dst)
|
||||||
|
assert.Equal(t, tc.log, fc.log)
|
||||||
|
assert.Equal(t, tc.hostCopy, strings.HasPrefix(fc.src, host))
|
||||||
|
if tc.log != nil {
|
||||||
|
assert.NoDirExists(t, fc.src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,11 +53,12 @@ runner:
|
|||||||
# scratch directories (left behind when a host cleanup delete stalls) older than
|
# scratch directories (left behind when a host cleanup delete stalls) older than
|
||||||
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
|
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
|
||||||
# (or any non-positive value) disables stale-directory cleanup entirely, along with
|
# (or any non-positive value) disables stale-directory cleanup entirely, along with
|
||||||
# the docker network cleanup below.
|
# the docker network and volume cleanup below.
|
||||||
#workdir_cleanup_age: 24h
|
#workdir_cleanup_age: 24h
|
||||||
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
|
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
|
||||||
# docker it removes the per-job networks of jobs this runner did not live to tear down,
|
# docker it removes the per-job networks and volumes of jobs this runner did not live to
|
||||||
# which would otherwise hold a subnet of the daemon address pool until the host is rebuilt.
|
# tear down, which would otherwise hold a subnet of the daemon address pool and the job's
|
||||||
|
# workspace contents until the host is rebuilt.
|
||||||
#idle_cleanup_interval: 10m
|
#idle_cleanup_interval: 10m
|
||||||
# The base interval for periodic log flush to the Gitea instance.
|
# The base interval for periodic log flush to the Gitea instance.
|
||||||
# Logs may be sent earlier if the buffer reaches log_report_batch_size
|
# Logs may be sent earlier if the buffer reaches log_report_batch_size
|
||||||
@@ -139,26 +140,20 @@ runner:
|
|||||||
# job_completed: ''
|
# job_completed: ''
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
# Enable the built-in cache server (used by actions/cache and similar actions).
|
# Enable caching (used by actions/cache and similar actions). Off means no built-in server is
|
||||||
|
# started and no job is registered with an external_server either.
|
||||||
#enabled: true
|
#enabled: true
|
||||||
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
|
# Directory where cache blobs are stored on disk. Default: $HOME/.cache/actcache
|
||||||
# Ignored when external_server is set.
|
# Ignored when external_server is set.
|
||||||
#dir: ""
|
#dir: ""
|
||||||
# Outbound IP or hostname that job containers use to reach this runner's cache server.
|
# Outbound IP or hostname that job containers use to reach this runner's cache server.
|
||||||
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
|
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
|
||||||
# If the runner itself runs in Docker, automatic detection can choose an
|
# A runner container on the jobs' Docker daemon attaches to each job network automatically.
|
||||||
# address on the runner container's network that job containers cannot reach
|
|
||||||
# when the runner creates a separate per-job network. In that case, set this
|
|
||||||
# to a hostname/IP reachable from job containers, and set port to a fixed
|
|
||||||
# published port or put the job containers on a shared Docker network.
|
|
||||||
# Ignored when external_server is set.
|
|
||||||
#host: ""
|
#host: ""
|
||||||
# Port for the built-in cache server. 0 picks a random free port.
|
# Port for the built-in cache server. 0 picks a random free port.
|
||||||
# Ignored when external_server is set.
|
|
||||||
#port: 0
|
#port: 0
|
||||||
# URL of a shared `gitea-runner cache-server` to use instead of starting a local one.
|
# URL of a shared `gitea-runner cache-server` to forward cache calls to.
|
||||||
# Set on every runner that should share a cache pool. A trailing slash is optional.
|
# Set on every runner that should share a cache pool. A trailing slash is optional.
|
||||||
# Jobs reach the server at this URL too, so set it to the reverse proxy when one fronts the server.
|
|
||||||
# Example: "http://cache-host:8088/"
|
# Example: "http://cache-host:8088/"
|
||||||
# Requires external_secret (below) to match the value on the cache-server.
|
# Requires external_secret (below) to match the value on the cache-server.
|
||||||
#external_server: ""
|
#external_server: ""
|
||||||
@@ -171,17 +166,31 @@ cache:
|
|||||||
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
|
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
|
||||||
# Setting both external_secret and external_secret_file is an error.
|
# Setting both external_secret and external_secret_file is an error.
|
||||||
#external_secret_file: ""
|
#external_secret_file: ""
|
||||||
|
# Store the cache in an S3-compatible bucket such as AWS S3, MinIO or RustFS. Runners with
|
||||||
|
# the same settings use the same cache. Cannot be combined with external_server. Add lifecycle
|
||||||
|
# rules to the bucket that expire old caches and abort incomplete multipart uploads.
|
||||||
|
# region defaults to us-east-1, and path_style to false for AWS and true otherwise.
|
||||||
|
# access_key and secret_key can also be set directly instead of read from files.
|
||||||
|
#s3:
|
||||||
|
# endpoint: "http://minio:9000"
|
||||||
|
# region: "us-east-1"
|
||||||
|
# bucket: "runner-cache"
|
||||||
|
# prefix: "gitea-runner-cache"
|
||||||
|
# path_style: true
|
||||||
|
# access_key_file: "/run/secrets/cache-s3-access-key"
|
||||||
|
# secret_key_file: "/run/secrets/cache-s3-secret-key"
|
||||||
# When true, reuse a cached action instead of fetching from the remote on every job.
|
# When true, reuse a cached action instead of fetching from the remote on every job.
|
||||||
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
|
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
|
||||||
# until its cache entry expires or is manually removed.
|
# until its cache entry expires or is manually removed.
|
||||||
#offline_mode: false
|
#offline_mode: false
|
||||||
# Serve the actions cache service v2 API. The actions that use it refuse any host they do not
|
# Serve the actions cache service v2 API. The actions that use it fall back to v1 on any host
|
||||||
# take for GitHub, so reaching it means editing that check out of their own bundle, undone
|
# they do not take for GitHub, so reaching it means editing that check out of their own bundle,
|
||||||
# whenever it is downloaded again. That edit is made either way, this only governs the API
|
# put back after the copy into the job. That edit is made either way, this only governs the API
|
||||||
# advertised. A bundle that does not match is left alone.
|
# advertised. A bundle that does not match is left alone. With v2, artifact uploads go via the
|
||||||
|
# cache server.
|
||||||
#v2: true
|
#v2: true
|
||||||
# How the cache server discards entries, ignored when external_server is set since that
|
# How the cache server discards entries, ignored when external_server or s3 is set since
|
||||||
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three
|
# those apply their own. Leave a setting out for its default; 0s or 0 turns the three
|
||||||
# limits off. Sizes accept 10GB, 512mb, 1TiB or a plain byte count, binary either way.
|
# limits off. Sizes accept 10GB, 512mb, 1TiB or a plain byte count, binary either way.
|
||||||
# Whatever these allow, the cache still sheds entries to keep free space on its volume
|
# Whatever these allow, the cache still sheds entries to keep free space on its volume
|
||||||
# above health_check.min_free_disk_space_mb when health checks are enabled.
|
# above health_check.min_free_disk_space_mb when health checks are enabled.
|
||||||
@@ -198,9 +207,8 @@ cache:
|
|||||||
container:
|
container:
|
||||||
# Specifies the network to which the container will connect.
|
# Specifies the network to which the container will connect.
|
||||||
# Could be host, bridge or the name of a custom network.
|
# Could be host, bridge or the name of a custom network.
|
||||||
|
# A runner container outside bridge cannot join it, so jobs on bridge cannot reach its cache.
|
||||||
# If it's empty, runner will create a network automatically.
|
# If it's empty, runner will create a network automatically.
|
||||||
# For dockerized runners using the built-in cache server, a custom shared
|
|
||||||
# network can be required so job containers can reach cache.host/cache.port.
|
|
||||||
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
||||||
#network: ""
|
#network: ""
|
||||||
# network_create_options only apply when `network` is left empty and the runner
|
# network_create_options only apply when `network` is left empty and the runner
|
||||||
@@ -229,7 +237,8 @@ container:
|
|||||||
# If it's empty, /workspace will be used.
|
# If it's empty, /workspace will be used.
|
||||||
# Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup.
|
# Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup.
|
||||||
#workdir_parent:
|
#workdir_parent:
|
||||||
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
|
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/bmatcuk/doublestar#patterns
|
||||||
|
# `*` matches within a single path segment, `**` also matches across path separators.
|
||||||
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
|
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
|
||||||
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
|
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
|
||||||
# valid_volumes:
|
# valid_volumes:
|
||||||
@@ -258,7 +267,8 @@ container:
|
|||||||
#docker_timeout: 0s
|
#docker_timeout: 0s
|
||||||
# Mount the workspace from a host directory instead of a Docker volume, so jobs
|
# Mount the workspace from a host directory instead of a Docker volume, so jobs
|
||||||
# can bind-mount it by its own path into sibling containers (".:/app" in docker
|
# can bind-mount it by its own path into sibling containers (".:/app" in docker
|
||||||
# compose). Not needed when workflows use the GITEA_DOCKER_WORKSPACE variable.
|
# compose). Not needed when jobs get the Docker proxy or workflows use the
|
||||||
|
# GITEA_DOCKER_WORKSPACE variable.
|
||||||
# The workspace parent directory must be mounted into the runner container.
|
# The workspace parent directory must be mounted into the runner container.
|
||||||
#bind_workdir: false
|
#bind_workdir: false
|
||||||
# How long a job waits for a service container that declares a healthcheck to become
|
# How long a job waits for a service container that declares a healthcheck to become
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ type Runner struct {
|
|||||||
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
|
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
|
||||||
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
|
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
|
||||||
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
|
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
|
||||||
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories and orphaned host-mode scratch dirs older than this duration during idle cleanup.
|
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories, orphaned host-mode scratch dirs and orphaned docker job resources older than this duration during idle cleanup.
|
||||||
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs the idle cleanup (stale directories and orphaned docker networks) periodically while the runner is idle. Set to 0 to disable cleanup cadence.
|
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs the idle cleanup (stale directories and orphaned docker networks and volumes) periodically while the runner is idle. Set to 0 to disable cleanup cadence.
|
||||||
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
|
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
|
||||||
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
|
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
|
||||||
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
|
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
|
||||||
@@ -101,13 +101,28 @@ type Cache struct {
|
|||||||
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
|
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
|
||||||
V2 *bool `yaml:"v2"` // V2 advertises the actions cache service v2 API to jobs. The bundle edit that reaches it is made either way, the artifact actions need it too. Unset means enabled.
|
V2 *bool `yaml:"v2"` // V2 advertises the actions cache service v2 API to jobs. The bundle edit that reaches it is made either way, the artifact actions need it too. Unset means enabled.
|
||||||
|
|
||||||
// Eviction settings, ignored when ExternalServer is set since that server applies its own.
|
S3 *CacheS3 `yaml:"s3"` // S3 stores the cache in an S3-compatible bucket, so runners with the same settings use the same cache.
|
||||||
|
|
||||||
|
// Eviction settings, ignored when ExternalServer is set since that server applies its own. With S3, bucket lifecycle rules replace all but SweepInterval.
|
||||||
Retention time.Duration `yaml:"retention"` // Retention removes entries nothing has read or written within this window. Default 168h, 0 keeps them regardless of age.
|
Retention time.Duration `yaml:"retention"` // Retention removes entries nothing has read or written within this window. Default 168h, 0 keeps them regardless of age.
|
||||||
RepoSizeLimit Size `yaml:"repo_size_limit"` // RepoSizeLimit caps one repository, evicting least recently accessed first. Default 10GB, 0 is no limit.
|
RepoSizeLimit Size `yaml:"repo_size_limit"` // RepoSizeLimit caps one repository, evicting least recently accessed first. Default 10GB, 0 is no limit.
|
||||||
SizeLimit Size `yaml:"size_limit"` // SizeLimit caps the whole cache the same way. No limit by default.
|
SizeLimit Size `yaml:"size_limit"` // SizeLimit caps the whole cache the same way. No limit by default.
|
||||||
SweepInterval time.Duration `yaml:"sweep_interval"` // SweepInterval is the minimum time between two eviction sweeps. Default 1h; a cadence has no "off".
|
SweepInterval time.Duration `yaml:"sweep_interval"` // SweepInterval is the minimum time between two eviction sweeps. Default 1h; a cadence has no "off".
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheS3 is an S3-compatible bucket that stores the cache.
|
||||||
|
type CacheS3 struct {
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
Region string `yaml:"region"`
|
||||||
|
Bucket string `yaml:"bucket"`
|
||||||
|
Prefix string `yaml:"prefix"`
|
||||||
|
PathStyle *bool `yaml:"path_style"`
|
||||||
|
AccessKey string `yaml:"access_key"`
|
||||||
|
AccessKeyFile string `yaml:"access_key_file"`
|
||||||
|
SecretKey string `yaml:"secret_key"`
|
||||||
|
SecretKeyFile string `yaml:"secret_key_file"`
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultCache returns the cache eviction defaults, seeded before the file is read so a
|
// DefaultCache returns the cache eviction defaults, seeded before the file is read so a
|
||||||
// written 0 can mean off. SizeLimit stays zero: the free space floor bounds the whole cache.
|
// written 0 can mean off. SizeLimit stays zero: the free space floor bounds the whole cache.
|
||||||
func DefaultCache() Cache {
|
func DefaultCache() Cache {
|
||||||
@@ -266,7 +281,7 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
cfg.Cache.Enabled = &b
|
cfg.Cache.Enabled = &b
|
||||||
}
|
}
|
||||||
// Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled.
|
// Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled.
|
||||||
if err := resolveCacheExternalSecret(cfg); err != nil {
|
if err := resolveSecretFile("cache.external_secret", &cfg.Cache.ExternalSecret, cfg.Cache.ExternalSecretFile); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if *cfg.Cache.Enabled {
|
if *cfg.Cache.Enabled {
|
||||||
@@ -280,6 +295,9 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
|
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
|
||||||
return nil, errors.New("cache.external_server is set but no shared secret is configured; set cache.external_secret (or cache.external_secret_file) to the same value used by the gitea-runner cache-server")
|
return nil, errors.New("cache.external_server is set but no shared secret is configured; set cache.external_secret (or cache.external_secret_file) to the same value used by the gitea-runner cache-server")
|
||||||
}
|
}
|
||||||
|
if err := resolveCacheS3(&cfg.Cache); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if cfg.Container.WorkdirParent == "" {
|
if cfg.Container.WorkdirParent == "" {
|
||||||
cfg.Container.WorkdirParent = "workspace"
|
cfg.Container.WorkdirParent = "workspace"
|
||||||
@@ -391,10 +409,10 @@ func warnUnknownKeys(file string, content []byte) {
|
|||||||
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
||||||
decoder.KnownFields(true)
|
decoder.KnownFields(true)
|
||||||
|
|
||||||
var typeErr *yaml.TypeError
|
var loadErrs *yaml.LoadErrors
|
||||||
if err := decoder.Decode(&Config{}); errors.As(err, &typeErr) {
|
if err := decoder.Decode(&Config{}); errors.As(err, &loadErrs) {
|
||||||
for _, message := range typeErr.Errors {
|
for _, loadErr := range loadErrs.Errors {
|
||||||
log.Warnf("config file %q: %s, it will be ignored", file, message)
|
log.Warnf("config file %q: %s, it will be ignored", file, loadErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -426,23 +444,44 @@ func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
|
|||||||
return defined, nil
|
return defined, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveCacheExternalSecret loads cache.external_secret from the file named by cache.external_secret_file,
|
// resolveSecretFile reads key from key_file, so deployments can mount a secret instead of committing it.
|
||||||
// so deployments can mount the secret instead of committing it to the config file.
|
func resolveSecretFile(key string, secret *string, file string) error {
|
||||||
func resolveCacheExternalSecret(cfg *Config) error {
|
if file == "" {
|
||||||
if cfg.Cache.ExternalSecretFile == "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if cfg.Cache.ExternalSecret != "" {
|
if *secret != "" {
|
||||||
return errors.New("cache.external_secret and cache.external_secret_file are both set; configure only one of them")
|
return fmt.Errorf("%s and %s_file are both set; configure only one of them", key, key)
|
||||||
}
|
}
|
||||||
content, err := os.ReadFile(cfg.Cache.ExternalSecretFile)
|
content, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read cache.external_secret_file %q: %w", cfg.Cache.ExternalSecretFile, err)
|
return fmt.Errorf("read %s_file %q: %w", key, file, err)
|
||||||
}
|
}
|
||||||
secret := strings.TrimSpace(string(content))
|
*secret = strings.TrimSpace(string(content))
|
||||||
if secret == "" {
|
if *secret == "" {
|
||||||
return fmt.Errorf("cache.external_secret_file %q contains no secret", cfg.Cache.ExternalSecretFile)
|
return fmt.Errorf("%s_file %q contains no secret", key, file)
|
||||||
}
|
}
|
||||||
cfg.Cache.ExternalSecret = secret
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCacheS3(cache *Cache) error {
|
||||||
|
s3 := cache.S3
|
||||||
|
switch {
|
||||||
|
case s3 == nil:
|
||||||
|
return nil
|
||||||
|
case cache.ExternalServer != "":
|
||||||
|
return errors.New("cache.s3 and cache.external_server cannot both be set")
|
||||||
|
case s3.Endpoint == "" || s3.Bucket == "":
|
||||||
|
return errors.New("cache.s3.endpoint and cache.s3.bucket must be set")
|
||||||
|
}
|
||||||
|
if err := resolveSecretFile("cache.s3.access_key", &s3.AccessKey, s3.AccessKeyFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := resolveSecretFile("cache.s3.secret_key", &s3.SecretKey, s3.SecretKeyFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s3.AccessKey == "" || s3.SecretKey == "" {
|
||||||
|
return errors.New("cache.s3.access_key (or cache.s3.access_key_file) and cache.s3.secret_key (or cache.s3.secret_key_file) must be set")
|
||||||
|
}
|
||||||
|
s3.Prefix = strings.Trim(s3.Prefix, "/")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,36 @@ cache:
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_CacheS3(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
accessPath := filepath.Join(dir, "access.key")
|
||||||
|
require.NoError(t, os.WriteFile(accessPath, []byte("access\n"), 0o600))
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
content := `
|
||||||
|
cache:
|
||||||
|
s3:
|
||||||
|
endpoint: "http://minio:9000"
|
||||||
|
bucket: "caches"
|
||||||
|
prefix: "/runners/"
|
||||||
|
access_key_file: "` + accessPath + `"
|
||||||
|
secret_key: "secret"
|
||||||
|
`
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||||
|
|
||||||
|
cfg, err := LoadDefault(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, CacheS3{
|
||||||
|
Endpoint: "http://minio:9000", Bucket: "caches", Prefix: "runners",
|
||||||
|
AccessKey: "access", AccessKeyFile: accessPath, SecretKey: "secret",
|
||||||
|
}, *cfg.Cache.S3)
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content+` external_server: "http://cache.invalid/"
|
||||||
|
external_secret: "shh"
|
||||||
|
`), 0o600))
|
||||||
|
_, err = LoadDefault(path)
|
||||||
|
assert.ErrorContains(t, err, "cache.s3 and cache.external_server cannot both be set")
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadDefault_ToolCacheMode(t *testing.T) {
|
func TestLoadDefault_ToolCacheMode(t *testing.T) {
|
||||||
cfg, err := LoadDefault("")
|
cfg, err := LoadDefault("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -312,7 +312,19 @@ func allScalars(nodes []*yaml.Node) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quoteLeadingNewlines keeps a scalar starting with a newline out of block style, whose indentation indicator the encoder drops.
|
||||||
|
// TODO: remove once https://github.com/yaml/go-yaml/pull/396 is released.
|
||||||
|
func quoteLeadingNewlines(node *yaml.Node) {
|
||||||
|
if node.Kind == yaml.ScalarNode && strings.HasPrefix(node.Value, "\n") {
|
||||||
|
node.Style = yaml.DoubleQuotedStyle
|
||||||
|
}
|
||||||
|
for _, child := range node.Content {
|
||||||
|
quoteLeadingNewlines(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func encodeYAML(node *yaml.Node) ([]byte, error) {
|
func encodeYAML(node *yaml.Node) ([]byte, error) {
|
||||||
|
quoteLeadingNewlines(node)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
encoder := yaml.NewEncoder(&buf)
|
encoder := yaml.NewEncoder(&buf)
|
||||||
encoder.SetIndent(2)
|
encoder.SetIndent(2)
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ func TestEditValues(t *testing.T) {
|
|||||||
assert.Equal(t, map[string]string{"EXISTING": "value", "ADDED": "yes"}, cfg.Runner.Envs)
|
assert.Equal(t, map[string]string{"EXISTING": "value", "ADDED": "yes"}, cfg.Runner.Envs)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "set map entry starting with a newline before an indented line",
|
||||||
|
edit: func(file string) error { return SetValue(file, "runner.envs.ADDED", "\n indented\nplain\n") },
|
||||||
|
assert: func(t *testing.T, cfg *Config, _ string) {
|
||||||
|
assert.Equal(t, "\n indented\nplain\n", cfg.Runner.Envs["ADDED"])
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "set replaces a list",
|
name: "set replaces a list",
|
||||||
edit: func(file string) error { return SetValue(file, "runner.labels", "one", "two") },
|
edit: func(file string) error { return SetValue(file, "runner.labels", "one", "two") },
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package telemetry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
|
"github.com/avast/retry-go/v5"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||||
|
tracepb "go.opentelemetry.io/proto/otlp/trace/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const scope = "gitea.com/gitea/runner"
|
||||||
|
|
||||||
|
type taskRunKey struct{}
|
||||||
|
|
||||||
|
type taskRun struct {
|
||||||
|
id, url, pipeline string
|
||||||
|
span trace.Span
|
||||||
|
}
|
||||||
|
|
||||||
|
// tracer stays off the global provider and ctx carries only span contexts, so the Docker client's otelhttp records nothing.
|
||||||
|
var tracer trace.Tracer = tracenoop.Tracer{}
|
||||||
|
|
||||||
|
func SetTracerProvider(provider trace.TracerProvider) {
|
||||||
|
tracer = provider.Tracer(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Setup(ctx context.Context, uuid, name string) (func(context.Context) error, error) {
|
||||||
|
noop := func(context.Context) error { return nil }
|
||||||
|
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
|
||||||
|
if base := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); endpoint == "" && base != "" {
|
||||||
|
endpoint = strings.TrimSuffix(base, "/") + "/v1/traces"
|
||||||
|
}
|
||||||
|
if endpoint == "" || strings.EqualFold(os.Getenv("OTEL_SDK_DISABLED"), "true") || strings.EqualFold(os.Getenv("OTEL_TRACES_EXPORTER"), "none") {
|
||||||
|
return noop, nil
|
||||||
|
}
|
||||||
|
if protocol := cmp.Or(os.Getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"), os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL")); protocol != "" && !strings.EqualFold(protocol, "http/protobuf") {
|
||||||
|
return noop, fmt.Errorf("OTLP protocol %q is not supported, only http/protobuf is", protocol)
|
||||||
|
}
|
||||||
|
res, err := resource.New(ctx, resource.WithTelemetrySDK(), resource.WithAttributes(
|
||||||
|
semconv.ServiceName("gitea-runner"),
|
||||||
|
semconv.ServiceVersion(ver.Version()),
|
||||||
|
semconv.ServiceInstanceID(uuid),
|
||||||
|
semconv.CICDWorkerID(uuid),
|
||||||
|
semconv.CICDWorkerName(name),
|
||||||
|
), resource.WithFromEnv())
|
||||||
|
if err != nil {
|
||||||
|
return noop, err
|
||||||
|
}
|
||||||
|
headers := http.Header{}
|
||||||
|
for _, key := range []string{"OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_TRACES_HEADERS"} {
|
||||||
|
for pair := range strings.SplitSeq(os.Getenv(key), ",") {
|
||||||
|
key, value, found := strings.Cut(pair, "=")
|
||||||
|
if decoded, err := url.PathUnescape(strings.TrimSpace(value)); found && err == nil {
|
||||||
|
headers.Set(strings.TrimSpace(key), decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(otlptrace.NewUnstarted(&client{endpoint, headers})), sdktrace.WithResource(res))
|
||||||
|
SetTracerProvider(provider)
|
||||||
|
return provider.Shutdown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// client replaces otlptracehttp, which links gRPC (+7 MB): https://github.com/open-telemetry/opentelemetry-go/issues/2579
|
||||||
|
type client struct {
|
||||||
|
endpoint string
|
||||||
|
headers http.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportClient refuses redirects, which would carry the collector's header credentials to another host.
|
||||||
|
var exportClient = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
|
|
||||||
|
func (*client) Start(context.Context) error { return nil }
|
||||||
|
|
||||||
|
func (*client) Stop(context.Context) error { return nil }
|
||||||
|
|
||||||
|
func (c *client) UploadTraces(ctx context.Context, spans []*tracepb.ResourceSpans) error {
|
||||||
|
body, err := proto.Marshal(&tracepb.TracesData{ResourceSpans: spans})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return retry.New(retry.Context(ctx), retry.Attempts(5), retry.LastErrorOnly(true)).Do(func() error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return retry.Unrecoverable(err)
|
||||||
|
}
|
||||||
|
req.Header = c.headers.Clone()
|
||||||
|
req.Header.Set("Content-Type", "application/x-protobuf")
|
||||||
|
resp, err := exportClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode < http.StatusMultipleChoices {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err = fmt.Errorf("OTLP export failed: %s", resp.Status)
|
||||||
|
if slices.Contains([]int{http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout}, resp.StatusCode) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return retry.Unrecoverable(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartJob(ctx context.Context, task *runnerv1.Task) (context.Context, func(runnerv1.Result)) {
|
||||||
|
field := func(key string) string { return task.GetContext().GetFields()[key].GetStringValue() }
|
||||||
|
pipeline, owner, repo := field("workflow"), field("repository_owner"), field("repository")
|
||||||
|
repoURL := strings.TrimSuffix(field("server_url"), "/") + "/" + repo
|
||||||
|
runURL := repoURL + "/actions/runs/" + field("run_id")
|
||||||
|
_, span := tracer.Start(ctx, "RUN "+pipeline, trace.WithSpanKind(trace.SpanKindServer), trace.WithAttributes(
|
||||||
|
semconv.CICDPipelineName(pipeline),
|
||||||
|
semconv.CICDPipelineActionNameRun,
|
||||||
|
semconv.CICDPipelineRunID(field("run_id")),
|
||||||
|
semconv.CICDPipelineRunURLFull(runURL),
|
||||||
|
semconv.VCSOwnerName(owner),
|
||||||
|
semconv.VCSRepositoryName(strings.TrimPrefix(repo, owner+"/")),
|
||||||
|
semconv.VCSRepositoryURLFull(repoURL),
|
||||||
|
semconv.VCSRefHeadName(field("ref_name")),
|
||||||
|
semconv.VCSRefHeadRevision(field("sha")),
|
||||||
|
attribute.String("gitea.job.key", field("job")),
|
||||||
|
attribute.String("gitea.run.number", field("run_number")),
|
||||||
|
attribute.String("gitea.run.attempt", field("run_attempt")),
|
||||||
|
attribute.Int64("gitea.task.id", task.Id),
|
||||||
|
))
|
||||||
|
if !span.SpanContext().IsValid() {
|
||||||
|
return ctx, func(runnerv1.Result) {}
|
||||||
|
}
|
||||||
|
ctx = context.WithValue(trace.ContextWithSpanContext(ctx, span.SpanContext()), taskRunKey{}, taskRun{strconv.FormatInt(task.Id, 10), runURL, pipeline, span})
|
||||||
|
return ctx, func(result runnerv1.Result) {
|
||||||
|
end(span, semconv.CICDPipelineResultKey, jobResult(ctx, result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetJobName(ctx context.Context, job string) {
|
||||||
|
if run, ok := ctx.Value(taskRunKey{}).(taskRun); ok {
|
||||||
|
run.span.SetName("RUN " + run.pipeline + " " + job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddJobEnv(ctx context.Context, envs map[string]string) {
|
||||||
|
carrier := propagation.MapCarrier{}
|
||||||
|
propagation.TraceContext{}.Inject(ctx, carrier)
|
||||||
|
for key, value := range carrier {
|
||||||
|
envs[strings.ToUpper(key)] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartStep(ctx context.Context, step *model.Step, stage string) (context.Context, func(error, *model.StepResult)) {
|
||||||
|
parent, ok := ctx.Value(taskRunKey{}).(taskRun)
|
||||||
|
if !ok {
|
||||||
|
return ctx, func(error, *model.StepResult) {}
|
||||||
|
}
|
||||||
|
name, id := step.Name, parent.id+"."+strconv.Itoa(step.Number)
|
||||||
|
if name == "" {
|
||||||
|
run, _, _ := strings.Cut(strings.TrimSpace(step.Run), "\n")
|
||||||
|
name = "Run " + cmp.Or(strings.TrimSpace(run), step.String())
|
||||||
|
}
|
||||||
|
if stage != "Main" {
|
||||||
|
name, id = stage+" "+name, id+"."+strings.ToLower(stage)
|
||||||
|
}
|
||||||
|
_, span := tracer.Start(ctx, name, trace.WithAttributes(
|
||||||
|
semconv.CICDPipelineTaskName(name),
|
||||||
|
semconv.CICDPipelineTaskRunID(id),
|
||||||
|
semconv.CICDPipelineTaskRunURLFull(parent.url),
|
||||||
|
))
|
||||||
|
ctx = context.WithValue(trace.ContextWithSpanContext(ctx, span.SpanContext()), taskRunKey{}, taskRun{id, parent.url, parent.pipeline, span})
|
||||||
|
return ctx, func(err error, result *model.StepResult) {
|
||||||
|
end(span, semconv.CICDPipelineTaskRunResultKey, stepResult(ctx, err, result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobResult(ctx context.Context, result runnerv1.Result) string {
|
||||||
|
switch {
|
||||||
|
case result == runnerv1.Result_RESULT_SUCCESS:
|
||||||
|
return "success"
|
||||||
|
case result == runnerv1.Result_RESULT_SKIPPED:
|
||||||
|
return "skip"
|
||||||
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
|
return "timeout"
|
||||||
|
case result == runnerv1.Result_RESULT_CANCELLED:
|
||||||
|
return "cancellation"
|
||||||
|
case result == runnerv1.Result_RESULT_FAILURE:
|
||||||
|
return "failure"
|
||||||
|
}
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
func stepResult(ctx context.Context, err error, result *model.StepResult) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
|
return "timeout"
|
||||||
|
case errors.Is(err, context.Canceled) || ctx.Err() != nil:
|
||||||
|
return "cancellation"
|
||||||
|
case err != nil || result.Conclusion == model.StepStatusFailure:
|
||||||
|
return "failure"
|
||||||
|
case result.Conclusion == model.StepStatusSkipped:
|
||||||
|
return "skip"
|
||||||
|
}
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
func end(span trace.Span, key attribute.Key, result string) {
|
||||||
|
span.SetAttributes(key.String(result))
|
||||||
|
switch result {
|
||||||
|
case "timeout":
|
||||||
|
span.SetAttributes(semconv.ErrorTypeKey.String(result))
|
||||||
|
span.SetStatus(codes.Error, "")
|
||||||
|
case "failure", "error":
|
||||||
|
span.SetAttributes(semconv.ErrorTypeOther)
|
||||||
|
span.SetStatus(codes.Error, "")
|
||||||
|
}
|
||||||
|
span.End()
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package telemetry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||||
|
tracepb "go.opentelemetry.io/proto/otlp/trace/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setEnv(t *testing.T, env map[string]string) {
|
||||||
|
t.Cleanup(func() { SetTracerProvider(tracenoop.NewTracerProvider()) })
|
||||||
|
for _, key := range []string{
|
||||||
|
"OTEL_SDK_DISABLED", "OTEL_TRACES_EXPORTER", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||||
|
"OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
|
||||||
|
} {
|
||||||
|
t.Setenv(key, env[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetup(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
env map[string]string
|
||||||
|
enabled bool
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{},
|
||||||
|
{env: map[string]string{"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "http://collector:4318/v1/traces"}, enabled: true},
|
||||||
|
{env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", "OTEL_TRACES_EXPORTER": "NONE"}},
|
||||||
|
{env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", "OTEL_SDK_DISABLED": "TRUE"}},
|
||||||
|
{env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc"}, err: `OTLP protocol "grpc" is not supported, only http/protobuf is`},
|
||||||
|
} {
|
||||||
|
t.Run(fmt.Sprint(tc.env), func(t *testing.T) {
|
||||||
|
setEnv(t, tc.env)
|
||||||
|
shutdown, err := Setup(t.Context(), "uuid", "runner")
|
||||||
|
if tc.err != "" {
|
||||||
|
require.EqualError(t, err, tc.err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
_, disabled := tracer.(tracenoop.Tracer)
|
||||||
|
assert.Equal(t, tc.enabled, !disabled)
|
||||||
|
require.NoError(t, shutdown(t.Context()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportRetriesAndMatchesOTLP(t *testing.T) {
|
||||||
|
bodies := make(chan []byte, 2)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/otlp/v1/traces", r.URL.Path)
|
||||||
|
assert.Equal(t, "Bearer token", r.Header.Get("Authorization"))
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
bodies <- body
|
||||||
|
if len(bodies) == 1 {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
setEnv(t, map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": server.URL + "/otlp/", "OTEL_EXPORTER_OTLP_HEADERS": "authorization=Bearer%20token"})
|
||||||
|
|
||||||
|
shutdown, err := Setup(t.Context(), "uuid", "runner")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, span := tracer.Start(t.Context(), "op")
|
||||||
|
span.End()
|
||||||
|
require.NoError(t, shutdown(t.Context()))
|
||||||
|
|
||||||
|
require.Len(t, bodies, 2)
|
||||||
|
<-bodies
|
||||||
|
var decoded tracepb.TracesData
|
||||||
|
require.NoError(t, proto.Unmarshal(<-bodies, &decoded))
|
||||||
|
assert.Equal(t, "op", decoded.GetResourceSpans()[0].GetScopeSpans()[0].GetSpans()[0].GetName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportRefusesRedirects(t *testing.T) {
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("redirect followed") }))
|
||||||
|
defer target.Close()
|
||||||
|
origin := httptest.NewServer(http.RedirectHandler(target.URL, http.StatusTemporaryRedirect))
|
||||||
|
defer origin.Close()
|
||||||
|
require.EqualError(t, (&client{origin.URL, http.Header{}}).UploadTraces(t.Context(), nil), "OTLP export failed: 307 Temporary Redirect")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJob(t *testing.T) {
|
||||||
|
fields, err := structpb.NewStruct(map[string]any{
|
||||||
|
"workflow": "ci.yml",
|
||||||
|
"repository": "owner/repo",
|
||||||
|
"repository_owner": "owner",
|
||||||
|
"server_url": "https://gitea.example/",
|
||||||
|
"run_id": "42",
|
||||||
|
"run_number": "40",
|
||||||
|
"run_attempt": "2",
|
||||||
|
"job": "build",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
task := &runnerv1.Task{Id: 7, Context: fields}
|
||||||
|
|
||||||
|
envs := map[string]string{}
|
||||||
|
ctx, endJob := StartJob(t.Context(), task)
|
||||||
|
AddJobEnv(ctx, envs)
|
||||||
|
stepCtx, endStep := StartStep(ctx, &model.Step{}, "Main")
|
||||||
|
endStep(nil, nil)
|
||||||
|
endJob(runnerv1.Result_RESULT_SUCCESS)
|
||||||
|
assert.Equal(t, ctx, stepCtx)
|
||||||
|
assert.Empty(t, envs)
|
||||||
|
|
||||||
|
spans := tracetest.NewSpanRecorder()
|
||||||
|
SetTracerProvider(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans)))
|
||||||
|
t.Cleanup(func() { SetTracerProvider(tracenoop.NewTracerProvider()) })
|
||||||
|
|
||||||
|
ctx, endJob = StartJob(t.Context(), task)
|
||||||
|
SetJobName(ctx, "Build and test")
|
||||||
|
AddJobEnv(ctx, envs)
|
||||||
|
stepCtx, endStep = StartStep(ctx, &model.Step{Run: "make\nmake test", Number: 1}, "Main")
|
||||||
|
assert.False(t, trace.SpanFromContext(ctx).IsRecording())
|
||||||
|
assert.False(t, trace.SpanFromContext(stepCtx).IsRecording())
|
||||||
|
endStep(assert.AnError, nil)
|
||||||
|
endJob(runnerv1.Result_RESULT_FAILURE)
|
||||||
|
|
||||||
|
jobSpan := trace.SpanContextFromContext(ctx)
|
||||||
|
assert.Equal(t, "00-"+jobSpan.TraceID().String()+"-"+jobSpan.SpanID().String()+"-01", envs["TRACEPARENT"])
|
||||||
|
|
||||||
|
ended := spans.Ended()
|
||||||
|
require.Len(t, ended, 2)
|
||||||
|
step, job := ended[0], ended[1]
|
||||||
|
assert.Equal(t, "Run make", step.Name())
|
||||||
|
assert.Equal(t, jobSpan.SpanID(), step.Parent().SpanID())
|
||||||
|
assert.Equal(t, codes.Error, step.Status().Code)
|
||||||
|
assert.Subset(t, step.Attributes(), []any{
|
||||||
|
semconv.CICDPipelineTaskRunID("7.1"),
|
||||||
|
semconv.CICDPipelineTaskRunURLFull("https://gitea.example/owner/repo/actions/runs/42"),
|
||||||
|
semconv.ErrorTypeOther,
|
||||||
|
})
|
||||||
|
assert.Equal(t, "RUN ci.yml Build and test", job.Name())
|
||||||
|
assert.Equal(t, trace.SpanKindServer, job.SpanKind())
|
||||||
|
assert.Subset(t, job.Attributes(), []any{
|
||||||
|
semconv.VCSOwnerName("owner"),
|
||||||
|
semconv.VCSRepositoryName("repo"),
|
||||||
|
semconv.CICDPipelineRunID("42"),
|
||||||
|
attribute.String("gitea.run.number", "40"),
|
||||||
|
attribute.String("gitea.run.attempt", "2"),
|
||||||
|
attribute.String("gitea.job.key", "build"),
|
||||||
|
attribute.Int64("gitea.task.id", 7),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResults(t *testing.T) {
|
||||||
|
cancelled, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
expired, expire := context.WithDeadline(t.Context(), time.Time{})
|
||||||
|
defer expire()
|
||||||
|
for want, tc := range map[string]struct {
|
||||||
|
ctx context.Context
|
||||||
|
err error
|
||||||
|
result *model.StepResult
|
||||||
|
}{
|
||||||
|
"timeout": {ctx: t.Context(), err: fmt.Errorf("exec: %w", context.DeadlineExceeded)},
|
||||||
|
"cancellation": {ctx: cancelled},
|
||||||
|
"failure": {ctx: t.Context(), result: &model.StepResult{Outcome: model.StepStatusFailure, Conclusion: model.StepStatusFailure}},
|
||||||
|
"success": {ctx: t.Context(), result: &model.StepResult{Outcome: model.StepStatusFailure, Conclusion: model.StepStatusSuccess}},
|
||||||
|
"skip": {ctx: t.Context(), result: &model.StepResult{Outcome: model.StepStatusSkipped, Conclusion: model.StepStatusSkipped}},
|
||||||
|
} {
|
||||||
|
assert.Equal(t, want, stepResult(tc.ctx, tc.err, tc.result))
|
||||||
|
}
|
||||||
|
assert.Equal(t, "timeout", jobResult(expired, runnerv1.Result_RESULT_FAILURE))
|
||||||
|
assert.Equal(t, "cancellation", jobResult(t.Context(), runnerv1.Result_RESULT_CANCELLED))
|
||||||
|
}
|
||||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user