mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 14:55:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b14a38d4a | ||
|
|
179e435cd0 | ||
|
|
f963e9cfe8 | ||
|
|
e5aa9add02 | ||
|
|
329115b148 | ||
|
|
ff30f39b1b | ||
|
|
3802feb3fe | ||
|
|
27e9b4de87 | ||
|
|
4bedadef9d | ||
|
|
83b63250a3 | ||
|
|
2e59141ae9 | ||
|
|
020f0d2861 | ||
|
|
711b72735b | ||
|
|
436d0d2942 | ||
|
|
a284a62d96 | ||
|
|
2b1e64e414 | ||
|
|
ddee2e054a | ||
|
|
629e062f86 | ||
|
|
73e9552f06 | ||
|
|
05162998eb | ||
|
|
568f89d186 | ||
|
|
d93b7d278a | ||
|
|
0cedd98051 | ||
|
|
07ca4bfa7f | ||
|
|
78610b01f6 | ||
|
|
4a8ee4ce57 | ||
|
|
bf03f36d5a | ||
|
|
5a38a3d0c4 | ||
|
|
6618e67645 | ||
|
|
2b599fae7f | ||
|
|
990ebaa2ac | ||
|
|
81add27459 | ||
|
|
ace8653105 | ||
|
|
95daa50d9b | ||
|
|
1952e5cdb6 | ||
|
|
7ad12038d1 | ||
|
|
98816a605c | ||
|
|
e0d00e543d | ||
|
|
ae9ce10f71 | ||
|
|
3962d0d970 | ||
|
|
d83fa77de4 | ||
|
|
58ed5921f3 | ||
|
|
00ff71a879 | ||
|
|
80db5f61ac | ||
|
|
5310d8119a | ||
|
|
b9c6305dee | ||
|
|
699a97fc8c | ||
|
|
19afebc53f | ||
|
|
2cc3000369 | ||
|
|
3d116eb0c2 | ||
|
|
e0ce6776c7 | ||
|
|
69d5c22300 | ||
|
|
54978255f5 |
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@1f40c72289eff860ee54a304f1438e3cff362e0a # 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@1f40c72289eff860ee54a304f1438e3cff362e0a # 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
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
name: checks
|
name: checks
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# The runner image ships a stale docker.io login; point docker at an empty config so
|
# The runner image ships a stale docker.io login; point docker at an empty config so
|
||||||
@@ -30,8 +28,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:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # 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 +51,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"]
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ SHASUM ?= shasum -a 256
|
|||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||||
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
|
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
|
||||||
XGO_VERSION := go-1.27.x
|
XGO_VERSION := go-1.27.x
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]6 # renovate: datasource=go
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]7 # renovate: datasource=go
|
||||||
|
|
||||||
|
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
|
||||||
|
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:0e62e56b382ebf485bff1e51a05cbe27c708545a2e91425467fd31eb3e249521 # renovate: datasource=docker
|
||||||
|
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||||
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
|
E2E_K3S_IMAGE ?= rancher/k3s:v1.36.4-k3s1@sha256:edad48e12bf81c3a09ac1c05c0c0ffaaa22145980b989d6fae84543a76b83657 # renovate: datasource=docker
|
||||||
|
|
||||||
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
|
||||||
@@ -18,8 +24,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]3.2 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]4.0 # 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
|
||||||
|
|
||||||
@@ -158,17 +164,14 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
|||||||
@echo "Wrote .tmp/coverage.md"
|
@echo "Wrote .tmp/coverage.md"
|
||||||
|
|
||||||
.PHONY: test-dind
|
.PHONY: test-dind
|
||||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
test-dind: ## run the daemon-facing tests against the built dind image or podman (TARGET=dind|dind-rootless|podman)
|
||||||
@./scripts/test-dind.sh $(TARGET)
|
@PODMAN_TEST_IMAGE=$(PODMAN_TEST_IMAGE) ./scripts/test-dind.sh $(TARGET)
|
||||||
|
|
||||||
E2E_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
|
||||||
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
||||||
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh
|
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_K3S_IMAGE=$(E2E_K3S_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: $(GOFILES) ## install the runner binary via `go install`
|
install: $(GOFILES) ## install the runner binary via `go install`
|
||||||
|
|||||||
@@ -4,7 +4,19 @@
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
Docker Engine Community version is required for docker mode. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
|
On the runner:
|
||||||
|
|
||||||
|
- `git` 2.34.1 or newer on `PATH`, to download actions and reusable workflows.
|
||||||
|
- `docker` or `podman` with `podman.socket` enabled, for docker mode, Docker actions and `docker://` steps. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
|
||||||
|
|
||||||
|
Where steps run, in the job image or on the runner in host mode:
|
||||||
|
|
||||||
|
- `bash` or `sh` for `run` steps, `pwsh` or `powershell` on a Windows host.
|
||||||
|
- `node` for JavaScript actions and `hashFiles()`.
|
||||||
|
- `git` 2.34.1 or newer for [`builtin:checkout`](docs/builtin-actions.md).
|
||||||
|
- `git-lfs` and `ssh` for `builtin:checkout`, only when its `lfs` or `ssh-key` input is used.
|
||||||
|
|
||||||
|
Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images.
|
||||||
|
|
||||||
### Download pre-built binary
|
### Download pre-built binary
|
||||||
|
|
||||||
@@ -79,53 +91,24 @@ If the registry succeed, it will run immediately. Next time, you could run the r
|
|||||||
|
|
||||||
### Run with docker
|
### Run with docker
|
||||||
|
|
||||||
```bash
|
The recommended Docker-in-Docker image runs its own Docker daemon for jobs. Jobs never get the host's Docker socket, which is equivalent to root access on the host.
|
||||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:nightly
|
|
||||||
```
|
|
||||||
|
|
||||||
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
|
|
||||||
|
|
||||||
> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well — otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist.
|
|
||||||
|
|
||||||
### Image flavours
|
|
||||||
|
|
||||||
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
|
|
||||||
|
|
||||||
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
|
|
||||||
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
|
|
||||||
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
|
|
||||||
|
|
||||||
#### `latest` — basic
|
|
||||||
|
|
||||||
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
|
|
||||||
|
|
||||||
#### `latest-dind` — Docker-in-Docker
|
|
||||||
|
|
||||||
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||||
--name my_runner gitea/runner:latest-dind
|
-v "$PWD/data:/data" -v runner-docker:/var/lib/docker --name my_runner gitea/runner:nightly-dind
|
||||||
```
|
```
|
||||||
|
|
||||||
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged.
|
The volumes keep the runner's registration and the job image cache across container recreation.
|
||||||
|
|
||||||
#### `latest-dind-rootless` — rootless Docker-in-Docker
|
### Image flavours
|
||||||
|
|
||||||
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
|
| Tag | Docker daemon for jobs |
|
||||||
|
| --- | --- |
|
||||||
|
| `latest-dind` (recommended) | Bundled, needs `--privileged`. |
|
||||||
|
| `latest-dind-rootless` | Bundled, runs as a non-root user (UID 1000) for stronger isolation. Needs `--privileged` and [rootless Docker](https://docs.docker.com/engine/security/rootless/) support on the host. Keeps its image cache in `/home/rootless/.local/share/docker`. |
|
||||||
|
| `latest` | None. Mount the host's socket with `-v /var/run/docker.sock:/var/run/docker.sock`, or set `DOCKER_HOST`. |
|
||||||
|
|
||||||
> **The UID is fixed at 1000.** It comes from the `rootless` user baked into the upstream `docker:dind-rootless` base image, and the bundled daemon always listens on `/run/user/1000/docker.sock` inside the container, so running this flavour as a different user (`--user 1001`) does not work. If you need the runner to talk to a *host* rootless daemon that runs under some other UID, use the `basic` flavour instead and bind-mount that daemon's socket (see [examples/vm/rootless-docker.md](examples/vm/rootless-docker.md)); pointing `DOCKER_HOST` at a host socket from inside `dind-rootless` will not work. Changing the UID otherwise means rebuilding the image from a base with a different `rootless` user.
|
Versioned and `nightly` tags carry the same suffixes, for example `nightly-dind`. The images target Docker, not Podman.
|
||||||
|
|
||||||
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@@ -186,7 +169,7 @@ Of the [runner images](https://gitea.com/gitea/runner-images), the `-full` flavo
|
|||||||
|
|
||||||
#### Environment variables
|
#### Environment variables
|
||||||
|
|
||||||
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. Config values can read the runner's environment as `${NAME}`, for example `capacity: ${RUNNER_CAPACITY}`. An unset `NAME` fails the load, and `$${NAME}` is a literal `${NAME}`. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||||
|
|
||||||
### Labels
|
### Labels
|
||||||
|
|
||||||
@@ -201,10 +184,10 @@ A label is written as:
|
|||||||
| Part | Meaning |
|
| Part | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
||||||
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
|
| `schema` | `docker`, `kubernetes` or `host`. Defaults to `host` when omitted. |
|
||||||
| `args` | Only used by the `docker` schema: the image to run the job in. |
|
| `args` | Only used by the `docker` and `kubernetes` schemas: the image to run the job in. |
|
||||||
|
|
||||||
Two schemas are supported:
|
Three schemas are supported:
|
||||||
|
|
||||||
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
||||||
|
|
||||||
@@ -212,6 +195,12 @@ Two schemas are supported:
|
|||||||
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
|
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **`kubernetes://<image>`** — the job runs as a Kubernetes pod created from `<image>`, see **[docs/kubernetes.md](docs/kubernetes.md)**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest
|
||||||
|
```
|
||||||
|
|
||||||
- **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there:
|
- **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -226,9 +215,9 @@ ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
|
|||||||
|
|
||||||
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
|
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
|
||||||
|
|
||||||
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
Names may themselves contain a colon, for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`. Only `host`, `docker` and `kubernetes` are treated as schemas.
|
||||||
|
|
||||||
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
Jobs run on `runner.default_image` when no runner label matches their `runs-on`: as a pod on runners with `kubernetes` labels, otherwise in a container, or on the host when docker is unavailable. Suitable images are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||||
|
|
||||||
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
||||||
|
|
||||||
@@ -268,14 +257,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
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. Host jobs use their existing Docker access, where `.` works. The proxy removes the containers, networks and volumes a job creates, `docker compose` services included, after post steps and the completed hook. Set `container.sweep: false` when jobs deploy services to the runner's Docker daemon.
|
||||||
|
|
||||||
#### Proxy
|
#### Proxy
|
||||||
|
|
||||||
@@ -298,7 +287,7 @@ These hosts are added to `no_proxy` for jobs, so they are always reached directl
|
|||||||
|
|
||||||
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
||||||
|
|
||||||
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
For one step, set it in the step's `env:`, for the rest of a job, write it to `$GITHUB_ENV`. Setting it at workflow or job level or in `container.env` has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||||
|
|
||||||
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
||||||
|
|
||||||
@@ -306,65 +295,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, so the runner edits that check out of the bundle on its way into the job and puts the shared copy back afterwards. A bundle it does not recognise is left alone. 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. Set `runner.patch_actions: false` to leave bundles as shipped; the artifact actions then refuse 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.
|
||||||
|
|
||||||
With v2 the job's artifact calls go via the cache server, so jobs need to reach it to upload artifacts, not just to cache. `v2: false` sends them to Gitea directly.
|
**Eviction**
|
||||||
|
|
||||||
**Shared cache across multiple runners**
|
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.
|
||||||
|
|
||||||
Run one dedicated `gitea-runner cache-server` that all runners point at.
|
**Cache service v2**
|
||||||
|
|
||||||
1. Create a config file for the cache server host:
|
`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.
|
||||||
|
|
||||||
```yaml
|
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.
|
||||||
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:
|
|
||||||
|
|
||||||
```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
|
||||||
|
|
||||||
@@ -377,6 +369,7 @@ While the runner is idle it cleans up after earlier jobs:
|
|||||||
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
||||||
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
||||||
- on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
|
- on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
|
||||||
|
- on runners with `kubernetes` labels, job pods with that label older than `runner.workdir_cleanup_age` are removed the same way, together with their Secrets
|
||||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
||||||
|
|
||||||
#### Post-task script (`runner.post_task_script`)
|
#### Post-task script (`runner.post_task_script`)
|
||||||
@@ -399,6 +392,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, and the runner's metrics, 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.
|
||||||
|
|||||||
+210
-44
@@ -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,34 +225,74 @@ 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 {
|
||||||
@@ -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)
|
||||||
@@ -709,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
|
||||||
@@ -784,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 {
|
||||||
@@ -833,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
|
||||||
@@ -866,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)
|
||||||
}
|
}
|
||||||
@@ -875,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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -931,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)
|
||||||
@@ -971,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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,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)
|
||||||
@@ -1120,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"
|
||||||
@@ -56,6 +57,55 @@ func TestHandler_ExternalURL(t *testing.T) {
|
|||||||
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL())
|
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})
|
||||||
@@ -771,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,
|
||||||
@@ -814,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())
|
||||||
|
|
||||||
@@ -986,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))
|
||||||
|
|
||||||
@@ -1046,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()
|
||||||
@@ -1407,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)
|
||||||
@@ -1502,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"))
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const (
|
|||||||
blobUploadURLTTL = time.Hour
|
blobUploadURLTTL = time.Hour
|
||||||
|
|
||||||
twirpInternal = "internal"
|
twirpInternal = "internal"
|
||||||
|
twirpUnavailable = "unavailable"
|
||||||
twirpUnauthenticated = "unauthenticated"
|
twirpUnauthenticated = "unauthenticated"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -261,6 +262,8 @@ func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string
|
|||||||
switch code {
|
switch code {
|
||||||
case twirpInternal:
|
case twirpInternal:
|
||||||
status = http.StatusInternalServerError
|
status = http.StatusInternalServerError
|
||||||
|
case twirpUnavailable:
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
case twirpUnauthenticated:
|
case twirpUnauthenticated:
|
||||||
status = http.StatusUnauthorized
|
status = http.StatusUnauthorized
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ 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
|
||||||
@@ -81,9 +78,6 @@ func (h *Handler) mustProxy(r *http.Request, cred JobCredential, target *url.URL
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
scheme := r.Header.Get("X-Forwarded-Proto")
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
||||||
if base, err := url.Parse(cred.PublicURL); err == nil && base.Scheme != "" {
|
|
||||||
scheme = base.Scheme
|
|
||||||
}
|
|
||||||
if scheme == "" {
|
if scheme == "" {
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
}
|
}
|
||||||
@@ -98,4 +92,4 @@ func mergeQuery(target, request string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -54,11 +54,11 @@ func TestFrontResultsService(t *testing.T) {
|
|||||||
assert.Contains(t, body["msg"], "bearer")
|
assert.Contains(t, body["msg"], "bearer")
|
||||||
assert.NotEmpty(t, body["msg"])
|
assert.NotEmpty(t, body["msg"])
|
||||||
|
|
||||||
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: gitea.URL, PublicURL: "https://cache.example"})()
|
defer handler.RegisterJob(token, JobCredential{Repo: "owner/repo", Results: selfSigned.URL, InsecureTLS: true})()
|
||||||
|
|
||||||
status, _ = post(rpc)
|
status, _ = post(rpc)
|
||||||
require.Equal(t, http.StatusOK, status, "a registered https public URL onto an http instance must proxy, not redirect")
|
require.Equal(t, http.StatusOK, status, "an http job reaching an https instance must proxy")
|
||||||
assert.Equal(t, strings.TrimPrefix(gitea.URL, "http://"), gotHost, "Gitea must see the host it mints its URLs from")
|
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, rpc, gotPath)
|
assert.Equal(t, rpc, gotPath)
|
||||||
|
|
||||||
@@ -87,15 +87,15 @@ func TestFrontResultsService(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a trusted https instance is reached by the job itself",
|
name: "a trusted https instance is reached by the job itself",
|
||||||
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example"}, want: http.StatusTemporaryRedirect,
|
cred: JobCredential{Results: selfSigned.URL}, forwardedProto: "https", want: http.StatusTemporaryRedirect,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the job does not share this server's disregard for the certificate",
|
name: "the job does not share this server's disregard for the certificate",
|
||||||
cred: JobCredential{Results: selfSigned.URL, PublicURL: "https://cache.example", InsecureTLS: true}, want: http.StatusOK,
|
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: "an http job is not redirected to an https instance", cred: JobCredential{Results: selfSigned.URL}, want: http.StatusInternalServerError},
|
||||||
{
|
{
|
||||||
name: "a runner too old to send its public URL leaves the terminator to say so",
|
name: "a forwarded https scheme onto an http instance is proxied",
|
||||||
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
|
cred: JobCredential{Results: gitea.URL}, forwardedProto: "https", want: http.StatusOK,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
+213
-420
@@ -6,24 +6,23 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
"github.com/go-git/go-git/v5"
|
|
||||||
"github.com/go-git/go-git/v5/config"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/storer"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
||||||
"github.com/mattn/go-isatty"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +31,13 @@ var (
|
|||||||
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
||||||
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
||||||
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
||||||
|
hexRefRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
|
||||||
|
|
||||||
|
LocalEnvVars = []string{ // `git rev-parse --local-env-vars`, inherited ones point git at another repository
|
||||||
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT", "GIT_OBJECT_DIRECTORY",
|
||||||
|
"GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE", "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS",
|
||||||
|
"GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
|
||||||
|
}
|
||||||
|
|
||||||
cloneLocks lock.Keyed[string] // key: clone target directory
|
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||||
|
|
||||||
@@ -51,146 +57,60 @@ type Error struct {
|
|||||||
commit string
|
commit string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Error) Error() string {
|
func (e *Error) Error() string { return e.err.Error() }
|
||||||
return e.err.Error()
|
func (e *Error) Unwrap() error { return e.err }
|
||||||
}
|
func (e *Error) Commit() string { return e.commit }
|
||||||
|
|
||||||
func (e *Error) Unwrap() error {
|
|
||||||
return e.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Error) Commit() string {
|
|
||||||
return e.commit
|
|
||||||
}
|
|
||||||
|
|
||||||
// goGitMu serializes go-git repository access across the process. go-git is not safe for
|
|
||||||
// concurrent use of the same repository (even read access decodes packfiles into shared
|
|
||||||
// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded
|
|
||||||
// operations are fast local reads; gitea runs one job per process, so the lock is effectively
|
|
||||||
// uncontended in production.
|
|
||||||
var goGitMu sync.Mutex
|
|
||||||
|
|
||||||
// FindGitRevision get the current git revision
|
// FindGitRevision get the current git revision
|
||||||
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
func FindGitRevision(ctx context.Context, file string) (shortSHA, sha string, err error) {
|
||||||
goGitMu.Lock()
|
|
||||||
defer goGitMu.Unlock()
|
|
||||||
return findGitRevision(ctx, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
sha, err = gitOutput(ctx, file, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
gitDir, err := git.PlainOpenWithOptions(
|
|
||||||
file,
|
|
||||||
&git.PlainOpenOptions{
|
|
||||||
DetectDotGit: true,
|
|
||||||
EnableDotGitCommonDir: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WithError(err).Error("path", file, "not located inside a git repository")
|
logger.WithError(err).Error("path", file, "not located inside a git repository")
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
if len(sha) < 7 {
|
||||||
head, err := gitDir.Reference(plumbing.HEAD, true)
|
return "", "", errors.New("HEAD could not be resolved")
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
}
|
||||||
|
logger.Debugf("Found revision: %s", sha)
|
||||||
if head.Hash().IsZero() {
|
return sha[:7], sha, nil
|
||||||
return "", "", errors.New("HEAD sha1 could not be resolved")
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := head.Hash().String()
|
|
||||||
|
|
||||||
logger.Debugf("Found revision: %s", hash)
|
|
||||||
return hash[:7], strings.TrimSpace(hash), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindGitRef get the current git ref
|
// FindGitRef get the current git ref
|
||||||
func FindGitRef(ctx context.Context, file string) (string, error) {
|
func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||||
goGitMu.Lock()
|
|
||||||
defer goGitMu.Unlock()
|
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
logger.Debugf("Loading revision from git directory")
|
logger.Debugf("Loading revision from git directory")
|
||||||
_, ref, err := findGitRevision(ctx, file)
|
_, revision, err := FindGitRevision(ctx, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
logger.Debugf("HEAD points to '%s'", revision)
|
||||||
|
|
||||||
logger.Debugf("HEAD points to '%s'", ref)
|
refs, err := gitOutput(ctx, file, "for-each-ref", "--points-at=HEAD", "--format=%(refname)", "refs/tags", "refs/heads")
|
||||||
|
|
||||||
// Prefer the git library to iterate over the references and find a matching tag or branch.
|
|
||||||
refTag := ""
|
|
||||||
refBranch := ""
|
|
||||||
repo, err := git.PlainOpenWithOptions(
|
|
||||||
file,
|
|
||||||
&git.PlainOpenOptions{
|
|
||||||
DetectDotGit: true,
|
|
||||||
EnableDotGitCommonDir: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
var tag, branch string
|
||||||
iter, err := repo.References()
|
for ref := range strings.FieldsSeq(refs) {
|
||||||
if err != nil {
|
switch {
|
||||||
return "", err
|
case strings.HasPrefix(ref, "refs/tags/"):
|
||||||
}
|
tag = ref
|
||||||
|
case strings.HasPrefix(ref, "refs/heads/"):
|
||||||
// find the reference that matches the revision's has
|
branch = ref
|
||||||
err = iter.ForEach(func(r *plumbing.Reference) error {
|
|
||||||
/* tags and branches will have the same hash
|
|
||||||
* when a user checks out a tag, it is not mentioned explicitly
|
|
||||||
* in the go-git package, we must identify the revision
|
|
||||||
* then check if any tag matches that revision,
|
|
||||||
* if so then we checked out a tag
|
|
||||||
* else we look for branches and if matches,
|
|
||||||
* it means we checked out a branch
|
|
||||||
*
|
|
||||||
* If a branches matches first we must continue and check all tags (all references)
|
|
||||||
* in case we match with a tag later in the interation
|
|
||||||
*/
|
|
||||||
if r.Hash().String() == ref {
|
|
||||||
if r.Name().IsTag() {
|
|
||||||
refTag = r.Name().String()
|
|
||||||
}
|
|
||||||
if r.Name().IsBranch() {
|
|
||||||
refBranch = r.Name().String()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// we found what we where looking for
|
|
||||||
if refTag != "" && refBranch != "" {
|
|
||||||
return storer.ErrStop
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
if tag != "" {
|
||||||
// order matters here see above comment.
|
return tag, nil
|
||||||
if refTag != "" {
|
|
||||||
return refTag, nil
|
|
||||||
}
|
}
|
||||||
if refBranch != "" {
|
if branch != "" {
|
||||||
return refBranch, nil
|
return branch, nil
|
||||||
}
|
}
|
||||||
|
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", revision)
|
||||||
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", ref)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindGithubRepo get the repo
|
// FindGithubRepo get the repo
|
||||||
func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, error) {
|
func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, error) {
|
||||||
goGitMu.Lock()
|
url, err := findGitRemoteURL(ctx, file)
|
||||||
defer goGitMu.Unlock()
|
|
||||||
|
|
||||||
url, err := findGitRemoteURL(ctx, file, "origin")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -198,28 +118,16 @@ func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, e
|
|||||||
return slug, nil
|
return slug, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
func findGitRemoteURL(ctx context.Context, file string) (string, error) {
|
||||||
repo, err := git.PlainOpenWithOptions(
|
urls, err := gitOutput(ctx, file, "config", "--get-all", "remote.origin.url") // `remote get-url` applies insteadOf
|
||||||
file,
|
|
||||||
&git.PlainOpenOptions{
|
|
||||||
DetectDotGit: true,
|
|
||||||
EnableDotGitCommonDir: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if urls == "" {
|
||||||
remote, err := repo.Remote(remoteName)
|
return "", errors.New("remote 'origin' exists but has no URL")
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
url, _, _ := strings.Cut(urls, "\n")
|
||||||
if len(remote.Config().URLs) < 1 {
|
return url, nil
|
||||||
return "", fmt.Errorf("remote '%s' exists but has no URL", remoteName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return remote.Config().URLs[0], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGitSlug(url, githubInstance string) (string, string) {
|
func findGitSlug(url, githubInstance string) (string, string) {
|
||||||
@@ -260,96 +168,35 @@ type NewGitCloneExecutorInput struct {
|
|||||||
Quiet bool
|
Quiet bool
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
|
ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused.
|
// CloneIfRequired reports whether an existing local clone was reused.
|
||||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
func CloneIfRequired(ctx context.Context, input NewGitCloneExecutorInput, logger log.FieldLogger) (bool, error) {
|
||||||
r, err := git.PlainOpen(input.Dir)
|
var origin string
|
||||||
|
_, err := os.Stat(filepath.Join(input.Dir, ".git")) // without it git would discover a repository around Dir
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Verify the cached clone still points to the resolved URL before reusing it.
|
origin, err = findGitRemoteURL(ctx, input.Dir)
|
||||||
remote, err := r.Remote("origin")
|
|
||||||
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
|
|
||||||
// Reuse existing clone
|
|
||||||
return r, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
|
||||||
case len(remote.Config().URLs) == 0:
|
|
||||||
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
|
||||||
default:
|
|
||||||
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
|
||||||
}
|
|
||||||
if err := os.RemoveAll(input.Dir); err != nil {
|
|
||||||
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if err == nil && origin == input.URL {
|
||||||
var progressWriter io.Writer
|
return true, nil
|
||||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
|
||||||
if entry, ok := logger.(*log.Entry); ok {
|
|
||||||
progressWriter = entry.WriterLevel(log.DebugLevel)
|
|
||||||
} else if lgr, ok := logger.(*log.Logger); ok {
|
|
||||||
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
|
||||||
} else {
|
|
||||||
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
|
||||||
progressWriter = os.Stdout
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if err == nil {
|
||||||
cloneOptions := git.CloneOptions{
|
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, origin, input.URL)
|
||||||
URL: input.URL,
|
} else if _, statErr := os.Stat(input.Dir); statErr == nil {
|
||||||
Progress: progressWriter,
|
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||||
|
|
||||||
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
|
|
||||||
}
|
}
|
||||||
if input.Token != "" {
|
if err := os.RemoveAll(input.Dir); err != nil {
|
||||||
cloneOptions.Auth = &http.BasicAuth{
|
return false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
||||||
Username: "token",
|
|
||||||
Password: input.Token,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if err := clone(ctx, input); err != nil {
|
||||||
r, err = cloneAtDepth(ctx, input, cloneOptions, logger)
|
return false, err
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
|
||||||
return nil, false, err
|
|
||||||
}
|
}
|
||||||
|
if err := os.Chmod(input.Dir, 0o755); err != nil {
|
||||||
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
return false, err
|
||||||
return nil, false, err
|
|
||||||
}
|
}
|
||||||
|
return 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
|
||||||
@@ -362,170 +209,125 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
}
|
}
|
||||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||||
|
|
||||||
defer AcquireCloneLock(input.Dir)()
|
defer AcquireCloneLock(input.Dir)()
|
||||||
|
|
||||||
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
reused, err := CloneIfRequired(ctx, input, logger)
|
||||||
r, reused, err := CloneIfRequired(ctx, refName, input, logger)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
resolved, err := resolveRef(ctx, input.Dir, input.Ref)
|
||||||
isOfflineMode := input.OfflineMode
|
if errors.Is(err, ErrShortRef) || (err != nil && input.OfflineMode) {
|
||||||
|
return err
|
||||||
// fetch latest changes
|
|
||||||
fetchOptions, pullOptions := gitOptions(input.Token)
|
|
||||||
|
|
||||||
if input.InsecureSkipTLS { // For Gitea
|
|
||||||
fetchOptions.InsecureSkipTLS = true
|
|
||||||
pullOptions.InsecureSkipTLS = true
|
|
||||||
}
|
}
|
||||||
|
if !input.OfflineMode && (err != nil || (reused && !isFullHash(input.Ref, resolved))) {
|
||||||
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
if err := fetch(ctx, input); err != nil {
|
||||||
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
|
||||||
shallow := isShallow(r)
|
|
||||||
if shallow {
|
|
||||||
fetchOptions.Depth = 1
|
|
||||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
|
||||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
|
||||||
_, _, present := refRevision(r, input.Ref)
|
|
||||||
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
|
|
||||||
|
|
||||||
if refresh {
|
|
||||||
err = r.FetchContext(ctx, &fetchOptions)
|
|
||||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
resolved, err = resolveRef(ctx, input.Dir, input.Ref)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
var hash *plumbing.Hash
|
|
||||||
rev := plumbing.Revision(input.Ref)
|
|
||||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
|
||||||
// ResolveRevision returns a nil hash on error, and a branch ref legitimately fails
|
|
||||||
// here (no local refs/heads/<ref>); the duck-typing below resolves it.
|
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
|
||||||
} else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
|
|
||||||
return &Error{
|
|
||||||
err: ErrShortRef,
|
|
||||||
commit: hash.String(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rev, refType, _ := refRevision(r, input.Ref)
|
|
||||||
|
|
||||||
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)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := runGit(ctx, input.Dir, nil, "checkout", "--force", "--detach", resolved); err != nil {
|
||||||
var w *git.Worktree
|
return fmt.Errorf("checkout %s: %w", resolved, err)
|
||||||
if w, err = r.Worktree(); err != nil {
|
|
||||||
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 input.OfflineMode && 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{
|
|
||||||
Hash: *hash,
|
|
||||||
Force: true,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to checkout %s: %v", *hash, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = w.Reset(&git.ResetOptions{
|
|
||||||
Mode: git.HardReset,
|
|
||||||
Commit: *hash,
|
|
||||||
}); err != nil {
|
|
||||||
logger.Errorf("Unable to reset to %s: %v", hash.String(), err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debugf("Checked out %s", input.Ref)
|
logger.Debugf("Checked out %s", input.Ref)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloneAtDepth falls back to a full clone when the shallow attempt fails.
|
func clone(ctx context.Context, input NewGitCloneExecutorInput) error {
|
||||||
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
if input.Depth > 0 && isFullObjectID(input.Ref) {
|
||||||
|
if err := shallowPinnedClone(ctx, input); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if err := removePartialClone(input.Dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := []string{"clone", "--no-checkout"}
|
||||||
if input.Depth > 0 {
|
if input.Depth > 0 {
|
||||||
if plumbing.IsHash(input.Ref) {
|
args = append(args, "--no-local", "--depth", strconv.Itoa(input.Depth), "--no-tags")
|
||||||
r, err := fetchPinnedSHA(ctx, input, opts)
|
if input.Ref != "HEAD" && !strings.HasPrefix(input.Ref, "refs/") {
|
||||||
if err == nil {
|
args = append(args, "--branch", input.Ref)
|
||||||
return r, nil
|
}
|
||||||
}
|
}
|
||||||
logger.Debugf("Shallow fetch of %s at %s failed: %v", input.URL, input.Ref, err)
|
args = append(args, input.URL, input.Dir)
|
||||||
if err := removePartialClone(input.Dir); err != nil {
|
if _, err := runGit(ctx, "", &input, args...); err == nil {
|
||||||
return nil, err
|
return nil
|
||||||
}
|
} else if input.Depth == 0 {
|
||||||
} else {
|
return err
|
||||||
for _, refName := range []plumbing.ReferenceName{
|
}
|
||||||
plumbing.NewBranchReferenceName(input.Ref),
|
if err := removePartialClone(input.Dir); err != nil {
|
||||||
plumbing.NewTagReferenceName(input.Ref),
|
return err
|
||||||
} {
|
}
|
||||||
shallowOpts := opts
|
_, err := runGit(ctx, "", &input, "clone", "--no-checkout", input.URL, input.Dir)
|
||||||
shallowOpts.Depth = input.Depth
|
return err
|
||||||
shallowOpts.SingleBranch = true
|
}
|
||||||
shallowOpts.ReferenceName = refName
|
|
||||||
shallowOpts.Tags = git.NoTags
|
|
||||||
|
|
||||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
func shallowPinnedClone(ctx context.Context, input NewGitCloneExecutorInput) error {
|
||||||
if err == nil {
|
if _, err := runGit(ctx, "", &input, "init", "--object-format="+ObjectFormat(input.Ref), input.Dir); err != nil {
|
||||||
return r, nil
|
return err
|
||||||
}
|
}
|
||||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
if _, err := runGit(ctx, input.Dir, &input, "remote", "add", "origin", input.URL); err != nil {
|
||||||
if err := removePartialClone(input.Dir); err != nil {
|
return err
|
||||||
return nil, err
|
}
|
||||||
}
|
_, err := runGit(ctx, input.Dir, &input, "fetch", "--depth", strconv.Itoa(input.Depth), "--no-tags", "origin", "+"+input.Ref+":refs/pinned/"+input.Ref)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(ctx context.Context, input NewGitCloneExecutorInput) error {
|
||||||
|
args := []string{"fetch", "--force", "--prune"}
|
||||||
|
if _, err := os.Stat(filepath.Join(input.Dir, ".git", "shallow")); err == nil {
|
||||||
|
args = append(args, "--depth", "1", "--no-tags")
|
||||||
|
}
|
||||||
|
_, err := runGit(ctx, input.Dir, &input, append(args, "origin", "+"+input.Ref+":"+cachedRef(input.Ref))...) // git picks the remote ref, a tag before a branch
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachedRef(ref string) string {
|
||||||
|
return fmt.Sprintf("refs/runner/%x", sha256.Sum256([]byte(ref)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRef(ctx context.Context, dir, ref string) (string, error) {
|
||||||
|
if !isFullObjectID(ref) {
|
||||||
|
for _, name := range []string{cachedRef(ref), "refs/tags/" + ref, "refs/remotes/origin/" + ref} {
|
||||||
|
if sha, err := gitOutput(ctx, dir, "rev-parse", "--verify", name+"^{commit}"); err == nil {
|
||||||
|
return sha, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
|
||||||
}
|
}
|
||||||
|
sha, err := gitOutput(ctx, dir, "rev-parse", "--verify", ref+"^{commit}")
|
||||||
|
if err == nil && hexRefRegex.MatchString(ref) && len(sha) != len(ref) {
|
||||||
|
return "", &Error{err: ErrShortRef, commit: sha}
|
||||||
|
}
|
||||||
|
return sha, err
|
||||||
|
}
|
||||||
|
|
||||||
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
func isFullObjectID(ref string) bool {
|
||||||
|
return ObjectFormat(ref) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFullHash(ref, resolved string) bool {
|
||||||
|
return isFullObjectID(ref) && len(ref) == len(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectFormat returns the object format of a full commit hash, empty for anything else.
|
||||||
|
func ObjectFormat(sha string) string {
|
||||||
|
switch {
|
||||||
|
case !hexRefRegex.MatchString(sha):
|
||||||
|
return ""
|
||||||
|
case len(sha) == 40:
|
||||||
|
return "sha1"
|
||||||
|
case len(sha) == 64:
|
||||||
|
return "sha256"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func removePartialClone(dir string) error {
|
func removePartialClone(dir string) error {
|
||||||
@@ -535,71 +337,62 @@ func removePartialClone(dir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchPinnedSHA(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions) (*git.Repository, error) {
|
func gitOutput(ctx context.Context, dir string, args ...string) (string, error) {
|
||||||
r, err := git.PlainInit(input.Dir, false)
|
output, err := runGit(ctx, dir, nil, args...)
|
||||||
|
return strings.TrimSpace(string(output)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoteConfig(token string, insecureSkipTLS bool) []string {
|
||||||
|
var config []string
|
||||||
|
if insecureSkipTLS {
|
||||||
|
config = append(config, "http.sslVerify=false")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
config = append(config, "http.extraHeader=", "http.extraHeader=Authorization: Basic "+base64.StdEncoding.EncodeToString([]byte("token:"+token)))
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigEnv applies `-c` style entries through the environment, keeping secrets out of argv.
|
||||||
|
func ConfigEnv(config ...string) map[string]string {
|
||||||
|
env := map[string]string{"GIT_CONFIG_COUNT": strconv.Itoa(len(config))}
|
||||||
|
for i, entry := range config {
|
||||||
|
key, value, _ := strings.Cut(entry, "=")
|
||||||
|
env["GIT_CONFIG_KEY_"+strconv.Itoa(i)] = key
|
||||||
|
env["GIT_CONFIG_VALUE_"+strconv.Itoa(i)] = value
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGit(ctx context.Context, dir string, input *NewGitCloneExecutorInput, args ...string) ([]byte, error) {
|
||||||
|
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
|
||||||
|
if input != nil {
|
||||||
|
config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...)
|
||||||
|
if input.ClientCertFile != "" {
|
||||||
|
config = append(config, "http.sslCert="+input.ClientCertFile, "http.sslKey="+input.ClientKeyFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dir != "" {
|
||||||
|
args = append([]string{"-C", dir}, args...)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "git", args...)
|
||||||
|
cmd.Env = slices.DeleteFunc(os.Environ(), func(entry string) bool {
|
||||||
|
name, _, _ := strings.Cut(entry, "=")
|
||||||
|
return slices.Contains(LocalEnvVars, name)
|
||||||
|
})
|
||||||
|
for key, value := range ConfigEnv(config...) {
|
||||||
|
cmd.Env = append(cmd.Env, key+"="+value)
|
||||||
|
}
|
||||||
|
cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0")
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } // lets git remove its lock files
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = time.Second
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return output, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
|
||||||
}
|
}
|
||||||
if _, err := r.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{input.URL}}); err != nil {
|
return output, nil
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := r.FetchContext(ctx, &git.FetchOptions{
|
|
||||||
RefSpecs: []config.RefSpec{pinnedRefSpec(input.Ref)},
|
|
||||||
Depth: input.Depth,
|
|
||||||
Tags: git.NoTags,
|
|
||||||
Auth: opts.Auth,
|
|
||||||
Progress: opts.Progress,
|
|
||||||
InsecureSkipTLS: opts.InsecureSkipTLS,
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pinnedRefSpec keeps a hash-shaped name out of refs/heads and refs/tags.
|
|
||||||
func pinnedRefSpec(sha string) config.RefSpec {
|
|
||||||
return config.RefSpec(fmt.Sprintf("+%s:refs/pinned/%s", sha, sha))
|
|
||||||
}
|
|
||||||
|
|
||||||
// refRevision picks the revision to check out and reports whether it resolves locally.
|
|
||||||
func refRevision(r *git.Repository, ref string) (plumbing.Revision, string, bool) {
|
|
||||||
if plumbing.IsHash(ref) {
|
|
||||||
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
|
|
||||||
_, err := r.CommitObject(plumbing.NewHash(ref))
|
|
||||||
return plumbing.Revision(ref), "sha", err == nil
|
|
||||||
}
|
|
||||||
if _, err := r.Tag(ref); err == nil {
|
|
||||||
return plumbing.Revision(path.Join("refs", "tags", ref)), "tag", true
|
|
||||||
}
|
|
||||||
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
|
|
||||||
if _, err := r.Reference(remoteRef, false); err == nil {
|
|
||||||
return plumbing.Revision(remoteRef), "branch", true
|
|
||||||
}
|
|
||||||
rev := plumbing.Revision(ref)
|
|
||||||
_, err := r.ResolveRevision(rev)
|
|
||||||
return rev, "sha", err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
|
||||||
func isShallow(r *git.Repository) bool {
|
|
||||||
shallows, err := r.Storer.Shallow()
|
|
||||||
return err == nil && len(shallows) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
|
|
||||||
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
|
||||||
tagRef := plumbing.NewTagReferenceName(ref)
|
|
||||||
if _, err := r.Reference(tagRef, false); err == nil {
|
|
||||||
return config.RefSpec(fmt.Sprintf("+%s:%s", tagRef, tagRef)), true
|
|
||||||
}
|
|
||||||
remoteRef := plumbing.NewRemoteReferenceName("origin", ref)
|
|
||||||
if _, err := r.Reference(remoteRef, false); err == nil {
|
|
||||||
branchRef := plumbing.NewBranchReferenceName(ref)
|
|
||||||
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
|
||||||
}
|
|
||||||
if plumbing.IsHash(ref) {
|
|
||||||
// The broad refspec carries only advertised tips, never a pinned commit.
|
|
||||||
return pinnedRefSpec(ref), true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
+111
-130
@@ -6,7 +6,7 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -16,18 +16,12 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
gogit "github.com/go-git/go-git/v5"
|
|
||||||
gogitconfig "github.com/go-git/go-git/v5/config"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
||||||
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
|
||||||
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -89,14 +83,27 @@ func cleanGitHooks(dir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindGithubRepoUsesOrigin(t *testing.T) {
|
func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
basedir := t.TempDir()
|
basedir := t.TempDir()
|
||||||
const remoteURL = "https://github.com/owner/repo.git"
|
const remoteURL = "https://github.com/owner/repo.git"
|
||||||
require.NoError(t, gitCmd("init", basedir))
|
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir))
|
||||||
require.NoError(t, cleanGitHooks(basedir))
|
require.NoError(t, cleanGitHooks(basedir))
|
||||||
|
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
||||||
|
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
|
||||||
|
|
||||||
url, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
shortSHA, sha, err := FindGitRevision(context.Background(), basedir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha)
|
||||||
|
require.Len(t, sha, 64)
|
||||||
|
require.Equal(t, sha[:7], shortSHA)
|
||||||
|
|
||||||
|
ref, err := FindGitRef(context.Background(), basedir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "refs/heads/main", ref)
|
||||||
|
|
||||||
|
url, err := findGitRemoteURL(context.Background(), basedir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, remoteURL, url)
|
require.Equal(t, remoteURL, url)
|
||||||
|
|
||||||
@@ -106,6 +113,7 @@ func TestFindGithubRepoUsesOrigin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGitFindRef(t *testing.T) {
|
func TestGitFindRef(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
basedir := t.TempDir()
|
basedir := t.TempDir()
|
||||||
|
|
||||||
for name, tt := range map[string]struct {
|
for name, tt := range map[string]struct {
|
||||||
@@ -192,7 +200,9 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
workDir := t.TempDir()
|
workDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
||||||
@@ -206,6 +216,13 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
fullSha := strings.TrimSpace(string(out))
|
fullSha := strings.TrimSpace(string(out))
|
||||||
|
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4]))
|
||||||
|
|
||||||
|
hostConfig := filepath.Join(t.TempDir(), "gitconfig")
|
||||||
|
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n[url \""+remoteDir+"\"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
|
||||||
|
t.Setenv("GIT_CONFIG_GLOBAL", hostConfig)
|
||||||
|
t.Setenv("GIT_DIR", t.TempDir())
|
||||||
|
|
||||||
for name, tt := range map[string]struct {
|
for name, tt := range map[string]struct {
|
||||||
Err error
|
Err error
|
||||||
Ref string
|
Ref string
|
||||||
@@ -226,26 +243,42 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
Err: &Error{ErrShortRef, fullSha},
|
Err: &Error{ErrShortRef, fullSha},
|
||||||
Ref: fullSha[:7],
|
Ref: fullSha[:7],
|
||||||
},
|
},
|
||||||
|
"HEAD": {
|
||||||
|
Ref: "HEAD",
|
||||||
|
},
|
||||||
|
"hex branch named like its own commit": {
|
||||||
|
Ref: fullSha[:4],
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir,
|
URL: "https://example.invalid/action",
|
||||||
Ref: tt.Ref,
|
Ref: tt.Ref,
|
||||||
Dir: t.TempDir(),
|
Dir: dir,
|
||||||
})
|
})
|
||||||
|
|
||||||
err := clone(context.Background())
|
err := clone(context.Background())
|
||||||
if tt.Err != nil {
|
if tt.Err != nil {
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, tt.Err, err)
|
assert.Equal(t, tt.Err, err)
|
||||||
} else {
|
assert.Equal(t, tt.Err, clone(context.Background()), "a retry on the cache keeps the error")
|
||||||
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
return
|
||||||
}
|
}
|
||||||
|
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
marker := filepath.Join(dir, "marker")
|
||||||
|
require.NoError(t, os.WriteFile(marker, nil, 0o644))
|
||||||
|
require.NoError(t, clone(context.Background()))
|
||||||
|
assert.FileExists(t, marker, "an insteadOf rewrite must not evict the cache")
|
||||||
|
script, err := os.ReadFile(filepath.Join(dir, "action.sh"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "#!/bin/sh\necho hi\n", string(script), "host core.autocrlf must not convert action files")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
createRemote := func(message string) string {
|
createRemote := func(message string) string {
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
@@ -269,6 +302,11 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
|||||||
Dir: cacheDir,
|
Dir: cacheDir,
|
||||||
})(t.Context()))
|
})(t.Context()))
|
||||||
|
|
||||||
|
nested := filepath.Join(cacheDir, "nested")
|
||||||
|
require.NoError(t, os.Mkdir(nested, 0o755))
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: oldRemoteDir, Ref: "main", Dir: nested})(t.Context()))
|
||||||
|
assert.DirExists(t, filepath.Join(nested, ".git"), "an empty cache dir inside a clone of the same URL gets its own repository")
|
||||||
|
|
||||||
markerPath := filepath.Join(cacheDir, "stale-marker")
|
markerPath := filepath.Join(cacheDir, "stale-marker")
|
||||||
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
||||||
|
|
||||||
@@ -278,7 +316,7 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
|||||||
Dir: cacheDir,
|
Dir: cacheDir,
|
||||||
})(t.Context()))
|
})(t.Context()))
|
||||||
|
|
||||||
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
|
originURL, err := findGitRemoteURL(t.Context(), cacheDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, newRemoteDir, originURL)
|
assert.Equal(t, newRemoteDir, originURL)
|
||||||
|
|
||||||
@@ -290,61 +328,36 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
|||||||
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) {
|
||||||
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
t.Parallel()
|
||||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
|
||||||
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
|
|
||||||
|
|
||||||
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
|
||||||
// We need a working clone to push commits from.
|
|
||||||
workDir := t.TempDir()
|
workDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
// Create a feature branch (simulates refs/pull/N/head).
|
dir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "main", Dir: dir})
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "feature"))
|
|
||||||
|
|
||||||
// First clone via the executor — should succeed and cache the repo.
|
|
||||||
cloneDir := t.TempDir()
|
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
||||||
URL: remoteDir,
|
|
||||||
Ref: "main",
|
|
||||||
Dir: cloneDir,
|
|
||||||
})
|
|
||||||
require.NoError(t, clone(context.Background()))
|
require.NoError(t, clone(context.Background()))
|
||||||
|
|
||||||
// Now force-push the feature branch to a non-fast-forward commit (simulates
|
require.NoError(t, gitCmd("-C", workDir, "reset", "--hard", "HEAD~1"))
|
||||||
// a PR rebase). This makes refs/heads/feature non-fast-forward.
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "branch", "-D", "feature"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "feature"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "main"))
|
||||||
|
require.NoError(t, clone(context.Background()), "a non-fast-forward ref must update")
|
||||||
|
assert.Equal(t, "feature-rewritten", gitHeadSubject(t, dir))
|
||||||
|
|
||||||
// Also advance main so we can verify the clone picks up the new commit.
|
tagClone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "v9", Dir: t.TempDir()})
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
require.Error(t, tagClone(context.Background()))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "second"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v9"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v9"))
|
||||||
|
require.NoError(t, tagClone(context.Background()), "a tag published after a failed lookup must be fetched")
|
||||||
// Second clone to the same directory — before the fix this returned ErrForceNeeded
|
|
||||||
// and left the working tree at the old commit.
|
|
||||||
err := clone(context.Background())
|
|
||||||
require.NoError(t, err, "fetch with non-fast-forward refs must not fail when Force=true")
|
|
||||||
|
|
||||||
// Verify the working tree was actually updated to the latest main commit.
|
|
||||||
out, err := exec.Command("git", "-C", cloneDir, "log", "--oneline", "-1", "--format=%s").Output()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
// Build a local "remote" with a single commit on main.
|
// Build a local "remote" with a single commit on main.
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
@@ -391,6 +404,7 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
workDir := t.TempDir()
|
workDir := t.TempDir()
|
||||||
@@ -429,6 +443,7 @@ func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorShallow(t *testing.T) {
|
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
@@ -472,12 +487,11 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
return remote
|
return remote
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("commit hash falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
|
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||||
})(t.Context()))
|
})(t.Context()))
|
||||||
assert.NoFileExists(t, shallowMarker(dir))
|
|
||||||
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -504,55 +518,46 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
URL: remoteDir, Ref: "deadbeef", Dir: dir, Depth: 1,
|
||||||
})(t.Context()))
|
})
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
|
||||||
// Advance main on the remote, then reuse the existing shallow clone.
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||||
|
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, clone(t.Context()))
|
||||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
|
||||||
})(t.Context()))
|
|
||||||
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
||||||
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
func TestGitCloneExecutorColdCloneSkipsRefresh(t *testing.T) {
|
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
|
||||||
remoteDir := t.TempDir()
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--object-format=sha256", "--initial-branch=main", remote))
|
||||||
workDir := t.TempDir()
|
work := t.TempDir()
|
||||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", work))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
require.NoError(t, gitCmd("-C", work, "remote", "add", "origin", remote))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
|
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
require.NoError(t, gitCmd("-C", work, "push", "origin", "main"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
sha := gitRevParse(t, work, "HEAD")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
dir := t.TempDir()
|
||||||
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
|
||||||
for name, tt := range map[string]struct {
|
require.NoError(t, clone(t.Context()))
|
||||||
Ref string
|
assert.FileExists(t, shallowMarker(dir))
|
||||||
Depth int
|
require.ErrorIs(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha[:40], Dir: dir, Depth: 1, OfflineMode: true})(t.Context()), ErrShortRef)
|
||||||
}{
|
require.NoError(t, os.RemoveAll(remote))
|
||||||
"shallow branch": {"main", 1},
|
require.NoError(t, clone(t.Context()))
|
||||||
"full clone tag": {"v1", 0},
|
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||||
} {
|
})
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
counter := installCountingTransport(t)
|
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
||||||
URL: remoteDir, Ref: tt.Ref, Dir: t.TempDir(), Depth: tt.Depth,
|
|
||||||
})(t.Context()))
|
|
||||||
assert.Equal(t, int64(1), counter.sessions.Load())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
workDir := t.TempDir()
|
workDir := t.TempDir()
|
||||||
@@ -576,25 +581,6 @@ func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type countingTransport struct {
|
|
||||||
transport.Transport
|
|
||||||
sessions atomic.Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
|
|
||||||
c.sessions.Add(1)
|
|
||||||
return c.Transport.NewUploadPackSession(ep, auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func installCountingTransport(t *testing.T) *countingTransport {
|
|
||||||
t.Helper()
|
|
||||||
counter := &countingTransport{Transport: gogitfile.DefaultClient}
|
|
||||||
gogitclient.InstallProtocol("file", counter)
|
|
||||||
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
|
|
||||||
|
|
||||||
return counter
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitRevParse(t *testing.T, dir, rev string) string {
|
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||||
@@ -695,12 +681,17 @@ func TestAcquireCloneLock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An unresponsive remote must not pin a job: the refresh has to be interruptible.
|
// An unresponsive remote must not pin a job: the refresh has to be interruptible.
|
||||||
func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
block := make(chan struct{})
|
block := make(chan struct{})
|
||||||
reached := make(chan struct{})
|
reached := make(chan struct{})
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
var authorization string
|
||||||
once.Do(func() { close(reached) })
|
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
|
||||||
|
once.Do(func() {
|
||||||
|
authorization = request.Header.Get("Authorization")
|
||||||
|
close(reached)
|
||||||
|
})
|
||||||
<-block
|
<-block
|
||||||
}))
|
}))
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
@@ -709,17 +700,15 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "cached-action")
|
dir := filepath.Join(t.TempDir(), "cached-action")
|
||||||
repo, err := gogit.PlainInit(dir, false)
|
require.NoError(t, gitCmd("init", dir))
|
||||||
require.NoError(t, err)
|
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
|
||||||
_, err = repo.CreateRemote(&gogitconfig.RemoteConfig{Name: "origin", URLs: []string{server.URL}})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir})(ctx)
|
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir, Token: "secret"})(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -727,6 +716,7 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
|||||||
case <-time.After(10 * time.Second):
|
case <-time.After(10 * time.Second):
|
||||||
t.Fatal("the executor never reached the remote")
|
t.Fatal("the executor never reached the remote")
|
||||||
}
|
}
|
||||||
|
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), authorization)
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -736,12 +726,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))
|
|
||||||
}
|
|
||||||
+40
-34
@@ -10,6 +10,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type interfaceIP struct {
|
||||||
|
net.IP
|
||||||
|
net.Interface
|
||||||
|
}
|
||||||
|
|
||||||
// GetOutboundIP returns an outbound IP address of this machine.
|
// GetOutboundIP returns an outbound IP address of this machine.
|
||||||
// It tries to access the internet and returns the local IP address of the connection.
|
// It tries to access the internet and returns the local IP address of the connection.
|
||||||
// If the machine cannot access the internet, it returns a preferred IP address from network interfaces.
|
// If the machine cannot access the internet, it returns a preferred IP address from network interfaces.
|
||||||
@@ -26,11 +31,7 @@ func GetOutboundIP() net.IP {
|
|||||||
|
|
||||||
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||||
if ifs, err := net.Interfaces(); err == nil {
|
if ifs, err := net.Interfaces(); err == nil {
|
||||||
type IP struct {
|
var ips []interfaceIP
|
||||||
net.IP
|
|
||||||
net.Interface
|
|
||||||
}
|
|
||||||
var ips []IP
|
|
||||||
for _, i := range ifs {
|
for _, i := range ifs {
|
||||||
if addrs, err := i.Addrs(); err == nil {
|
if addrs, err := i.Addrs(); err == nil {
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
@@ -42,40 +43,45 @@ func GetOutboundIP() net.IP {
|
|||||||
ip = v.IP
|
ip = v.IP
|
||||||
}
|
}
|
||||||
if ip.IsGlobalUnicast() {
|
if ip.IsGlobalUnicast() {
|
||||||
ips = append(ips, IP{ip, i})
|
ips = append(ips, interfaceIP{ip, i})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(ips) > 1 {
|
return preferredIP(ips)
|
||||||
sort.Slice(ips, func(i, j int) bool {
|
|
||||||
ifi := ips[i].Interface
|
|
||||||
ifj := ips[j].Interface
|
|
||||||
|
|
||||||
// ethernet is preferred
|
|
||||||
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
|
|
||||||
return vi
|
|
||||||
}
|
|
||||||
|
|
||||||
ipi := ips[i].IP
|
|
||||||
ipj := ips[j].IP
|
|
||||||
|
|
||||||
// IPv4 is preferred
|
|
||||||
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
|
|
||||||
return vi
|
|
||||||
}
|
|
||||||
|
|
||||||
// en0 is preferred to en1
|
|
||||||
if ifi.Name != ifj.Name {
|
|
||||||
return ifi.Name < ifj.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback
|
|
||||||
return ipi.String() < ipj.String()
|
|
||||||
})
|
|
||||||
return ips[0].IP
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func preferredIP(ips []interfaceIP) net.IP {
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Slice(ips, func(i, j int) bool {
|
||||||
|
ifi := ips[i].Interface
|
||||||
|
ifj := ips[j].Interface
|
||||||
|
|
||||||
|
// ethernet is preferred
|
||||||
|
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
|
||||||
|
return vi
|
||||||
|
}
|
||||||
|
|
||||||
|
ipi := ips[i].IP
|
||||||
|
ipj := ips[j].IP
|
||||||
|
|
||||||
|
// IPv4 is preferred
|
||||||
|
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
|
||||||
|
return vi
|
||||||
|
}
|
||||||
|
|
||||||
|
// en0 is preferred to en1
|
||||||
|
if ifi.Name != ifj.Name {
|
||||||
|
return ifi.Name < ifj.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
return ipi.String() < ipj.String()
|
||||||
|
})
|
||||||
|
return ips[0].IP
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreferredIP(t *testing.T) {
|
||||||
|
assert.Nil(t, preferredIP(nil))
|
||||||
|
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}}))
|
||||||
|
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{
|
||||||
|
{IP: net.ParseIP("192.0.2.1"), Name: "wlan0"},
|
||||||
|
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -72,6 +75,9 @@ const (
|
|||||||
HealthUnhealthy = string(container.Unhealthy)
|
HealthUnhealthy = string(container.Unhealthy)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// marks what a runner creates for its jobs, telling its leftovers from another runner's
|
||||||
|
const runnerUUIDLabel = "com.gitea.runner.uuid"
|
||||||
|
|
||||||
// ErrContainerNotFound reports a container the daemon no longer knows. Its text is a
|
// ErrContainerNotFound reports a container the daemon no longer knows. Its text is a
|
||||||
// fragment, missingContainerError composes it into the message every operation shares.
|
// fragment, missingContainerError composes it into the message every operation shares.
|
||||||
var ErrContainerNotFound = errors.New("does not exist")
|
var ErrContainerNotFound = errors.New("does not exist")
|
||||||
@@ -82,6 +88,11 @@ type DockerProxy struct {
|
|||||||
close func(context.Context) error
|
close func(context.Context) error
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
closeErr error
|
closeErr error
|
||||||
|
mounts atomic.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DockerProxy) SetMounts(mounts map[string]string) {
|
||||||
|
p.mounts.Store(mounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DockerProxy) Close(ctx context.Context) error {
|
func (p *DockerProxy) Close(ctx context.Context) error {
|
||||||
@@ -101,6 +112,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
|
||||||
|
|||||||
@@ -46,14 +46,13 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
if _, err := cli.ImageInspect(ctx, imageName); cerrdefs.IsNotFound(err) {
|
||||||
if cerrdefs.IsNotFound(err) {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
|
if _, err = cli.ImageRemove(ctx, imageName, client.ImageRemoveOptions{
|
||||||
Force: force,
|
Force: force,
|
||||||
PruneChildren: pruneChildren,
|
PruneChildren: pruneChildren,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
+151
-43
@@ -10,21 +10,20 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
networkCreateAttempts = 3
|
networkCreateAttempts = 3
|
||||||
networkCreateRetryDelay = time.Second
|
networkCreateRetryDelay = time.Second
|
||||||
|
|
||||||
// marks the networks a runner creates for its jobs, so it can tell its own leftovers from
|
|
||||||
// those of another runner sharing the daemon
|
|
||||||
runnerUUIDLabel = "com.gitea.runner.uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
|
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
|
||||||
@@ -32,17 +31,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 +50,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 +59,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
|
||||||
@@ -78,45 +87,54 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
return createDockerNetwork(ctx, cli, name, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only create the network if it doesn't exist
|
func createDockerNetwork(ctx context.Context, cli client.APIClient, name string, opts NewDockerNetworkCreateExecutorInput) error {
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
// Only create the network if it doesn't exist
|
||||||
if err != nil {
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var options map[string]string
|
||||||
|
// For Gitea, reduce log noise
|
||||||
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
|
for _, n := range networks.Items {
|
||||||
|
if n.Name == name {
|
||||||
|
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// the daemon's mtu only reaches its default bridge, Docker's default is left out to keep default-network-opts
|
||||||
|
if mtu := n.Options["com.docker.network.driver.mtu"]; n.Name == network.NetworkBridge && mtu != "" && mtu != "1500" {
|
||||||
|
options = map[string]string{"com.docker.network.driver.mtu": mtu}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range networkCreateAttempts {
|
||||||
|
if i > 0 {
|
||||||
|
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||||
|
Driver: "bridge",
|
||||||
|
Scope: "local",
|
||||||
|
EnableIPv4: opts.EnableIPv4,
|
||||||
|
EnableIPv6: opts.EnableIPv6,
|
||||||
|
Options: options,
|
||||||
|
Labels: runnerLabels(opts.RunnerUUID),
|
||||||
|
}); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isAddressPoolExhausted(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
|
||||||
// common.Logger(ctx).Debugf("%v", networks)
|
|
||||||
for _, n := range networks.Items {
|
|
||||||
if n.Name == name {
|
|
||||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range networkCreateAttempts {
|
|
||||||
if i > 0 {
|
|
||||||
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
|
||||||
Driver: "bridge",
|
|
||||||
Scope: "local",
|
|
||||||
EnableIPv4: opts.EnableIPv4,
|
|
||||||
EnableIPv6: opts.EnableIPv6,
|
|
||||||
Labels: runnerLabels(opts.RunnerUUID),
|
|
||||||
}); err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !isAddressPoolExhausted(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runnerLabels(runnerUUID string) map[string]string {
|
func runnerLabels(runnerUUID string) map[string]string {
|
||||||
@@ -134,6 +152,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 +234,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,116 @@ 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 TestCreateDockerNetworkInheritsDaemonMTU(t *testing.T) {
|
||||||
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
ctx := context.Background()
|
||||||
// test on its own, since testify has no expectation to match it against.
|
for _, tc := range []struct {
|
||||||
|
bridgeOptions, want map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
map[string]string{"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1", "com.docker.network.driver.mtu": "1450"},
|
||||||
|
map[string]string{"com.docker.network.driver.mtu": "1450"},
|
||||||
|
},
|
||||||
|
{map[string]string{"com.docker.network.driver.mtu": "1500"}, nil},
|
||||||
|
} {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{}).
|
||||||
|
Return(mobyclient.NetworkListResult{Items: []network.Summary{{Name: network.NetworkBridge, Options: tc.bridgeOptions}}}, nil)
|
||||||
|
client.On("NetworkCreate", ctx, "job-network", mobyclient.NetworkCreateOptions{Driver: "bridge", Scope: "local", Options: tc.want}).
|
||||||
|
Return(mobyclient.NetworkCreateResult{}, nil)
|
||||||
|
require.NoError(t, createDockerNetwork(ctx, client, "job-network", NewDockerNetworkCreateExecutorInput{}))
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkConnect(ctx context.Context, id string, opts mobyclient.NetworkConnectOptions) (mobyclient.NetworkConnectResult, error) {
|
||||||
|
return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkDisconnect(ctx context.Context, id string, opts mobyclient.NetworkDisconnectOptions) (mobyclient.NetworkDisconnectResult, error) {
|
||||||
|
return mobyclient.NetworkDisconnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectAndDisconnectNetwork(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
connect := func(answer error) error {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
|
||||||
|
Container: "0123456789ab",
|
||||||
|
EndpointConfig: &network.EndpointSettings{Aliases: []string{"0123456789ab"}},
|
||||||
|
}).Return(answer).Once()
|
||||||
|
return connectNetwork(ctx, client, "jobs", "0123456789ab")
|
||||||
|
}
|
||||||
|
require.NoError(t, connect(nil))
|
||||||
|
require.NoError(t, connect(errors.New("endpoint with name runner already exists in network jobs")))
|
||||||
|
require.NoError(t, connect(errors.New("container runner already connected to network jobs")))
|
||||||
|
require.ErrorIs(t, connect(context.Canceled), context.Canceled)
|
||||||
|
|
||||||
|
disconnect := func(answer error) error {
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("NetworkDisconnect", ctx, "jobs", mobyclient.NetworkDisconnectOptions{
|
||||||
|
Container: "0123456789ab", Force: true,
|
||||||
|
}).Return(answer).Once()
|
||||||
|
return disconnectNetwork(ctx, client, "jobs", "0123456789ab")
|
||||||
|
}
|
||||||
|
require.NoError(t, disconnect(cerrdefs.ErrNotFound))
|
||||||
|
require.NoError(t, disconnect(errors.New("container runner is not connected to the network jobs")))
|
||||||
|
require.ErrorIs(t, disconnect(context.Canceled), context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of this runner's networks, only the ones nothing but its own cache container is attached to
|
||||||
|
// and old enough to predate any job now starting are the runner's to reclaim. An unexpected
|
||||||
|
// NetworkRemove fails the test on its own, since testify has no expectation to match it against.
|
||||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
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 +126,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)
|
||||||
}
|
}
|
||||||
+211
-19
@@ -19,9 +19,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -64,18 +66,20 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
|
|||||||
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 {
|
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
dir, err := filepath.Abs(os.TempDir())
|
dir, daemonDir := runnerContainerWorkdir(probeCtx, cli)
|
||||||
if err != nil {
|
seen := daemonDir != ""
|
||||||
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
if !seen {
|
||||||
return nil
|
if dir, err = filepath.Abs(os.TempDir()); err == nil {
|
||||||
|
daemonDir = dir
|
||||||
|
seen, err = daemonSeesDir(probeCtx, cli, dir, daemonDir)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
seen, err := daemonSeesDir(probeCtx, cli, dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !seen {
|
if !seen {
|
||||||
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary filesystem, jobs get the daemon socket directly")
|
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@@ -84,13 +88,33 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
|
|||||||
proxy, err := StartDockerProxy(daemonSocket, dir, job)
|
proxy, err := StartDockerProxy(daemonSocket, dir, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
proxy.Socket = daemonDir + strings.TrimPrefix(proxy.Socket, dir)
|
||||||
return proxy
|
return proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir,
|
// runnerContainerWorkdir looks the runner's container up by hostname to find the daemon's path to its working directory.
|
||||||
// which is what a job's proxy socket mounted from there needs.
|
func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir, daemonDir string) {
|
||||||
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool, error) {
|
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-")
|
marker, err := os.CreateTemp(dir, "gitea-runner-probe-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -103,6 +127,16 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool,
|
|||||||
if err := marker.Close(); err != nil {
|
if err := marker.Close(); err != nil {
|
||||||
return false, err
|
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
|
||||||
@@ -113,7 +147,7 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool,
|
|||||||
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{
|
HostConfig: &container.HostConfig{Mounts: []mount.Mount{
|
||||||
{Type: mount.TypeBind, Source: marker.Name(), Target: "/gitea-runner-probe", ReadOnly: true},
|
{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) {
|
||||||
@@ -122,12 +156,13 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
seen, err := containerSeesMarker(ctx, cli, dir, created.ID, "/gitea-runner-probe")
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); err != nil {
|
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", err)
|
return false, fmt.Errorf("removing the docker proxy probe container failed: %w", removeErr)
|
||||||
}
|
}
|
||||||
return true, nil
|
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.
|
||||||
@@ -151,7 +186,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Join(err, os.RemoveAll(instance))
|
return nil, errors.Join(err, os.RemoveAll(instance))
|
||||||
}
|
}
|
||||||
if err := copyDockerSocketPermissions(socket, info); err != nil {
|
if err := copyDockerSocketPermissions(daemonSocket, socket, info); err != nil {
|
||||||
return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance))
|
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) {
|
||||||
@@ -165,6 +200,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
|||||||
},
|
},
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
}
|
}
|
||||||
|
proxy := &DockerProxy{Socket: socket}
|
||||||
streams, cancelStreams := context.WithCancel(context.Background())
|
streams, cancelStreams := context.WithCancel(context.Background())
|
||||||
creates, cancelCreates := context.WithCancel(context.Background())
|
creates, cancelCreates := context.WithCancel(context.Background())
|
||||||
var admission sync.Mutex
|
var admission sync.Mutex
|
||||||
@@ -200,7 +236,8 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
|||||||
r = r.WithContext(ctx)
|
r = r.WithContext(ctx)
|
||||||
if creating {
|
if creating {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
|
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
|
||||||
if err := addLabel(r, job); err != nil {
|
mounts, _ := proxy.mounts.Load().(map[string]string)
|
||||||
|
if err := rewriteCreate(r, job, mounts); err != nil {
|
||||||
status := http.StatusBadRequest
|
status := http.StatusBadRequest
|
||||||
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
|
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
|
||||||
status = http.StatusRequestEntityTooLarge
|
status = http.StatusRequestEntityTooLarge
|
||||||
@@ -219,7 +256,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
|||||||
defer close(served)
|
defer close(served)
|
||||||
_ = server.Serve(listener)
|
_ = server.Serve(listener)
|
||||||
}()
|
}()
|
||||||
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error {
|
proxy.close = func(ctx context.Context) error {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
admission.Lock()
|
admission.Lock()
|
||||||
@@ -233,10 +270,11 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
|||||||
handlers.Wait()
|
handlers.Wait()
|
||||||
transport.CloseIdleConnections()
|
transport.CloseIdleConnections()
|
||||||
return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
|
return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
|
||||||
}}, 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(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -255,6 +293,9 @@ func addLabel(r *http.Request, job string) error {
|
|||||||
if fields == nil {
|
if fields == nil {
|
||||||
fields = make(map[string]json.RawMessage)
|
fields = make(map[string]json.RawMessage)
|
||||||
}
|
}
|
||||||
|
if len(mounts) > 0 && !hasAmbiguousFields(body) {
|
||||||
|
translateBinds(fields, createPath.FindStringSubmatch(r.URL.Path)[2], mounts)
|
||||||
|
}
|
||||||
maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
|
maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
|
||||||
return strings.EqualFold(name, "Labels")
|
return strings.EqualFold(name, "Labels")
|
||||||
})
|
})
|
||||||
@@ -274,6 +315,155 @@ func addLabel(r *http.Request, job string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func translateBinds(fields map[string]json.RawMessage, kind string, mounts map[string]string) {
|
||||||
|
cleanedSource := func(source string) string {
|
||||||
|
cleaned := path.Clean(source)
|
||||||
|
if target := jobMount(cleaned, mounts); mounts[target] != "" {
|
||||||
|
return mounts[target] + cleaned[len(target):]
|
||||||
|
}
|
||||||
|
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 dockerProxyConnKey struct{}
|
||||||
|
|
||||||
type dockerProxyResponse struct {
|
type dockerProxyResponse struct {
|
||||||
@@ -380,7 +570,9 @@ func removeLabelled(ctx context.Context, cli client.APIClient, job string) error
|
|||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
||||||
errs = append(errs, err)
|
errs = 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"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/image"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
"github.com/moby/moby/api/types/volume"
|
"github.com/moby/moby/api/types/volume"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -111,6 +112,7 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
|
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
|
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) {
|
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)
|
||||||
}}}
|
}}}
|
||||||
@@ -147,13 +149,37 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
|
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
|
||||||
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`,
|
`{"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"}`,
|
"/networks/create", `{"Name":"n"}`,
|
||||||
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", `{"Name":"v","labels":null}`,
|
"/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"}}`,
|
`{"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", "",
|
"/volumes/create", "",
|
||||||
@@ -267,7 +293,9 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
|||||||
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once()
|
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{}, containerFailure).Once()
|
Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once()
|
||||||
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).Return(mobyclient.NetworkListResult{}, listFailure).Once()
|
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
|
||||||
|
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "stack_default", Scope: "swarm"}}}, listFailure).Once()
|
||||||
|
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).Once()
|
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
|
||||||
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
|
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
|
||||||
@@ -276,6 +304,7 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
|||||||
require.ErrorIs(t, err, containerFailure)
|
require.ErrorIs(t, err, containerFailure)
|
||||||
require.ErrorIs(t, err, listFailure)
|
require.ErrorIs(t, err, listFailure)
|
||||||
require.ErrorIs(t, err, volumeFailure)
|
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 container c1")
|
||||||
require.ErrorContains(t, err, "failed to remove volume app_data")
|
require.ErrorContains(t, err, "failed to remove volume app_data")
|
||||||
cli.AssertExpectations(t)
|
cli.AssertExpectations(t)
|
||||||
@@ -289,9 +318,12 @@ 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, direct, 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-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
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)
|
||||||
@@ -396,6 +428,12 @@ type probeClient struct {
|
|||||||
mobyclient.APIClient
|
mobyclient.APIClient
|
||||||
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
|
||||||
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
|
||||||
|
stat func(string) error
|
||||||
|
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) {
|
func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) {
|
||||||
@@ -410,43 +448,47 @@ func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyc
|
|||||||
return c.remove(ctx, id, opts)
|
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) {
|
func TestDaemonSeesDir(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
markers := make(map[string]bool)
|
markers := make(map[string]bool)
|
||||||
for _, testCase := range []struct {
|
for _, testCase := range []struct {
|
||||||
name string
|
name string
|
||||||
private bool
|
daemonDir string
|
||||||
removeErr error
|
removeErr error
|
||||||
|
seen bool
|
||||||
|
removed bool
|
||||||
}{
|
}{
|
||||||
{name: "cleanup after cancellation"},
|
{name: "cleanup after cancellation", daemonDir: dir, seen: true, removed: true},
|
||||||
{name: "private filesystem with stale marker", private: true},
|
{name: "daemon without the directory", daemonDir: filepath.Join(dir, "missing")},
|
||||||
{name: "cleanup error after cancellation", removeErr: errors.New("cleanup failed")},
|
{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) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
daemonDir := dir
|
|
||||||
if testCase.private {
|
|
||||||
daemonDir = t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(daemonDir, "probe"), []byte("stale"), 0o600))
|
|
||||||
}
|
|
||||||
removed := false
|
removed := false
|
||||||
cli := &probeClient{
|
cli := &probeClient{
|
||||||
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
require.Len(t, opts.HostConfig.Mounts, 1)
|
assert.Equal(t, []mount.Mount{{Type: mount.TypeBind, Source: testCase.daemonDir, Target: "/gitea-runner-probe", ReadOnly: true}}, opts.HostConfig.Mounts)
|
||||||
marker := opts.HostConfig.Mounts[0].Source
|
|
||||||
assert.Equal(t, mount.Mount{Type: mount.TypeBind, Source: marker, Target: "/gitea-runner-probe", ReadOnly: true}, opts.HostConfig.Mounts[0])
|
|
||||||
assert.False(t, markers[marker])
|
|
||||||
markers[marker] = true
|
|
||||||
info, err := os.Stat(marker)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, info.Mode().IsRegular())
|
|
||||||
cancel()
|
cancel()
|
||||||
if _, err := os.Stat(filepath.Join(daemonDir, filepath.Base(marker))); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(testCase.daemonDir); err != nil {
|
||||||
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
|
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
|
||||||
}
|
}
|
||||||
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
|
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) {
|
remove: func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||||
removed = true
|
removed = true
|
||||||
assert.Equal(t, "probe", id)
|
assert.Equal(t, "probe", id)
|
||||||
@@ -457,13 +499,45 @@ func TestDaemonSeesDir(t *testing.T) {
|
|||||||
return mobyclient.ContainerRemoveResult{}, testCase.removeErr
|
return mobyclient.ContainerRemoveResult{}, testCase.removeErr
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
seen, err := daemonSeesDir(ctx, cli, dir)
|
seen, err := daemonSeesDir(ctx, cli, dir, testCase.daemonDir)
|
||||||
require.ErrorIs(t, err, testCase.removeErr)
|
require.ErrorIs(t, err, testCase.removeErr)
|
||||||
assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen)
|
assert.Equal(t, testCase.seen, seen)
|
||||||
assert.Equal(t, !testCase.private, removed)
|
assert.Equal(t, testCase.removed, removed)
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, entries)
|
assert.Empty(t, entries)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -6,22 +6,43 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
func copyDockerSocketPermissions(socket string, info os.FileInfo) error {
|
func copyDockerSocketPermissions(daemonSocket, socket string, info os.FileInfo) error {
|
||||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("docker socket ownership is unavailable")
|
return errors.New("docker socket ownership is unavailable")
|
||||||
}
|
}
|
||||||
if err := os.Chown(socket, int(stat.Uid), int(stat.Gid)); err != nil {
|
groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid))
|
||||||
return err
|
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 {
|
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.Chmod(socket, info.Mode().Perm())
|
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
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
func copyDockerSocketPermissions(_ string, _ os.FileInfo) error {
|
func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error {
|
||||||
return errors.New("docker socket ownership cannot be preserved on Windows")
|
return errors.New("docker socket ownership cannot be preserved on Windows")
|
||||||
}
|
}
|
||||||
+96
-100
@@ -7,13 +7,14 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -24,16 +25,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"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/osfs"
|
|
||||||
"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 +254,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)
|
||||||
@@ -571,7 +589,8 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
|
|
||||||
// If the `privileged` config has been disabled, `copts.privileged` need to be forced to false,
|
// If the `privileged` config has been disabled, `copts.privileged` need to be forced to false,
|
||||||
// even if the user specifies `--privileged` in the options string.
|
// even if the user specifies `--privileged` in the options string.
|
||||||
if !hostConfig.Privileged {
|
if !hostConfig.Privileged && copts.privileged {
|
||||||
|
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", "--privileged")
|
||||||
copts.privileged = false
|
copts.privileged = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,6 +626,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
|
|
||||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||||
|
|
||||||
|
cr.sanitizeConfig(ctx, nil, containerConfig.HostConfig) // a dropped volume must not displace the runner's own
|
||||||
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
||||||
binds := hostConfig.Binds
|
binds := hostConfig.Binds
|
||||||
mounts := hostConfig.Mounts
|
mounts := hostConfig.Mounts
|
||||||
@@ -958,42 +978,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
|||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
}
|
}
|
||||||
}(tarFile)
|
}(tarFile)
|
||||||
tw := tar.NewWriter(tarFile)
|
if err := writeDirTar(ctx, tarFile, dstPath, srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID); err != nil {
|
||||||
|
|
||||||
srcPrefix := filepath.Dir(srcPath)
|
|
||||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
|
||||||
srcPrefix += string(filepath.Separator)
|
|
||||||
}
|
|
||||||
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
|
||||||
|
|
||||||
var ignorer gitignore.Matcher
|
|
||||||
if useGitIgnore {
|
|
||||||
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.Debugf("Error loading .gitignore: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ignorer = gitignore.NewMatcher(ps)
|
|
||||||
}
|
|
||||||
|
|
||||||
fc := &filecollector.FileCollector{
|
|
||||||
Ignorer: ignorer,
|
|
||||||
SrcPath: srcPath,
|
|
||||||
SrcPrefix: srcPrefix,
|
|
||||||
SkipGitDir: skipGitDir,
|
|
||||||
Handler: &filecollector.TarCollector{
|
|
||||||
TarWriter: tw,
|
|
||||||
UID: cr.UID,
|
|
||||||
GID: cr.GID,
|
|
||||||
DstDir: dstPath[1:],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tw.Close(); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,30 +1003,12 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
|
|||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
return cr.missingContainerError("copy to %s", dstPath)
|
return cr.missingContainerError("copy to %s", dstPath)
|
||||||
}
|
}
|
||||||
logger := common.Logger(ctx)
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
tw := tar.NewWriter(&buf)
|
if err := writeFilesTar(ctx, &buf, cr.UID, cr.GID, files...); err != nil {
|
||||||
for _, file := range files {
|
|
||||||
logger.Debugf("Writing entry to tarball %s len:%d", file.Name, len(file.Body))
|
|
||||||
hdr := &tar.Header{
|
|
||||||
Name: file.Name,
|
|
||||||
Mode: file.Mode,
|
|
||||||
Size: int64(len(file.Body)),
|
|
||||||
Uid: cr.UID,
|
|
||||||
Gid: cr.GID,
|
|
||||||
}
|
|
||||||
if err := tw.WriteHeader(hdr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := tw.Write([]byte(file.Body)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tw.Close(); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("Extracting content to '%s'", dstPath)
|
common.Logger(ctx).Debugf("Extracting content to '%s'", dstPath)
|
||||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
||||||
DestinationPath: dstPath,
|
DestinationPath: dstPath,
|
||||||
Content: &buf,
|
Content: &buf,
|
||||||
@@ -1151,6 +1118,10 @@ func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig, trusted *c
|
|||||||
// systempaths=unconfined lands in these two rather than in SecurityOpt
|
// systempaths=unconfined lands in these two rather than in SecurityOpt
|
||||||
resetOption(logger, "--security-opt", &hostConfig.MaskedPaths, trusted.MaskedPaths)
|
resetOption(logger, "--security-opt", &hostConfig.MaskedPaths, trusted.MaskedPaths)
|
||||||
resetOption(logger, "--security-opt", &hostConfig.ReadonlyPaths, trusted.ReadonlyPaths)
|
resetOption(logger, "--security-opt", &hostConfig.ReadonlyPaths, trusted.ReadonlyPaths)
|
||||||
|
resetOption(logger, "--log-driver", &hostConfig.LogConfig.Type, trusted.LogConfig.Type) // drivers run in the daemon, egressing from its network
|
||||||
|
resetOption(logger, "--log-opt", &hostConfig.LogConfig.Config, trusted.LogConfig.Config)
|
||||||
|
resetOption(logger, "--oom-score-adj", &hostConfig.OomScoreAdj, trusted.OomScoreAdj) // -1000 exempts it from the host OOM killer
|
||||||
|
resetOption(logger, "--oom-kill-disable", &hostConfig.OomKillDisable, trusted.OomKillDisable)
|
||||||
|
|
||||||
// a driver mounts what it likes, e.g. local with device= binds any host path, which
|
// a driver mounts what it likes, e.g. local with device= binds any host path, which
|
||||||
// valid_volumes never gets to see
|
// valid_volumes never gets to see
|
||||||
@@ -1192,9 +1163,9 @@ func parseOptionsHostConfig(options string) (*container.HostConfig, error) {
|
|||||||
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
|
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
|
||||||
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
|
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
|
||||||
|
|
||||||
if len(cr.input.ValidVolumes) > 0 {
|
if len(cr.input.ValidVolumes) > 0 {
|
||||||
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
|
|
||||||
// sanitize binds
|
// sanitize binds
|
||||||
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
|
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
|
||||||
for _, bind := range hostConfig.Binds {
|
for _, bind := range hostConfig.Binds {
|
||||||
@@ -1208,37 +1179,47 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
sanitizedBinds = append(sanitizedBinds, bind)
|
sanitizedBinds = append(sanitizedBinds, bind)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if matcher.isValid(parsed.Source, mount.Type(parsed.Type)) {
|
if matcher.isValid(parsed.Source, mount.Type(parsed.Type), parsed.ReadOnly) {
|
||||||
|
if matcher.needsNoCopy(parsed.Source, mount.Type(parsed.Type)) && (parsed.Volume == nil || !parsed.Volume.NoCopy) {
|
||||||
|
bind += ",nocopy"
|
||||||
|
}
|
||||||
sanitizedBinds = append(sanitizedBinds, bind)
|
sanitizedBinds = append(sanitizedBinds, bind)
|
||||||
} else {
|
} else {
|
||||||
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
|
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hostConfig.Binds = sanitizedBinds
|
hostConfig.Binds = sanitizedBinds
|
||||||
// sanitize mounts
|
|
||||||
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
|
|
||||||
for _, mt := range hostConfig.Mounts {
|
|
||||||
if matcher.isValid(mt.Source, mt.Type) {
|
|
||||||
sanitizedMounts = append(sanitizedMounts, mt)
|
|
||||||
} else {
|
|
||||||
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hostConfig.Mounts = sanitizedMounts
|
|
||||||
} else {
|
} else {
|
||||||
for _, bind := range hostConfig.Binds {
|
for _, bind := range hostConfig.Binds {
|
||||||
logger.Warnf("[%s] is not a valid volume, will be ignored", bind)
|
logger.Warnf("[%s] is not a valid volume, will be ignored", bind)
|
||||||
}
|
}
|
||||||
for _, mt := range hostConfig.Mounts {
|
hostConfig.Binds = []string{}
|
||||||
|
}
|
||||||
|
// sanitize mounts
|
||||||
|
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
|
||||||
|
for _, mt := range hostConfig.Mounts {
|
||||||
|
readOnly := mt.ReadOnly && (mt.BindOptions == nil || !mt.BindOptions.ReadOnlyNonRecursive) // bind-recursive=writable leaves submounts writable
|
||||||
|
if matcher.isValid(mt.Source, mt.Type, readOnly) {
|
||||||
|
if matcher.needsNoCopy(mt.Source, mt.Type) {
|
||||||
|
mt.VolumeOptions = cmp.Or(mt.VolumeOptions, &mount.VolumeOptions{})
|
||||||
|
mt.VolumeOptions.NoCopy = true
|
||||||
|
}
|
||||||
|
sanitizedMounts = append(sanitizedMounts, mt)
|
||||||
|
} else {
|
||||||
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
||||||
}
|
}
|
||||||
hostConfig.Binds = []string{}
|
|
||||||
hostConfig.Mounts = []mount.Mount{}
|
|
||||||
}
|
}
|
||||||
|
hostConfig.Mounts = sanitizedMounts
|
||||||
|
|
||||||
return config, hostConfig
|
return config, hostConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string {
|
||||||
|
cr := &containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}
|
||||||
|
_, hostConfig := cr.sanitizeConfig(ctx, nil, &container.HostConfig{Binds: binds})
|
||||||
|
return hostConfig.Binds
|
||||||
|
}
|
||||||
|
|
||||||
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
||||||
func bindTarget(bind string) string {
|
func bindTarget(bind string) string {
|
||||||
parsed, err := loader.ParseVolume(bind)
|
parsed, err := loader.ParseVolume(bind)
|
||||||
@@ -1268,28 +1249,39 @@ func overlayVolumes(dst, src *container.HostConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type validVolumeMatcher struct {
|
type validVolumeMatcher struct {
|
||||||
allowAll bool
|
allowAll bool
|
||||||
named []*glob.Pattern
|
allowAllReadOnly bool
|
||||||
host []*glob.Pattern
|
named []validVolumeRule
|
||||||
|
host []validVolumeRule
|
||||||
|
}
|
||||||
|
|
||||||
|
type validVolumeRule struct {
|
||||||
|
pattern string
|
||||||
|
readOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
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.Pattern, 0, len(validVolumes)),
|
named: make([]validVolumeRule, 0, len(validVolumes)),
|
||||||
host: make([]*glob.Pattern, 0, len(validVolumes)),
|
host: make([]validVolumeRule, 0, len(validVolumes)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range validVolumes {
|
for _, v := range validVolumes {
|
||||||
|
v, readOnly := strings.CutSuffix(v, ":ro")
|
||||||
if v == "**" {
|
if v == "**" {
|
||||||
ret.allowAll = true
|
if readOnly {
|
||||||
|
ret.allowAllReadOnly = true
|
||||||
|
} else {
|
||||||
|
ret.allowAll = true
|
||||||
|
}
|
||||||
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, validVolumeRule{pattern: v, readOnly: readOnly})
|
||||||
} else {
|
} else {
|
||||||
ret.named = append(ret.named, g)
|
logger.Errorf("invalid volume pattern %s", v)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1298,40 +1290,44 @@ 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, validVolumeRule{pattern: normalized, readOnly: readOnly})
|
||||||
} else {
|
} else {
|
||||||
ret.host = append(ret.host, g)
|
logger.Errorf("invalid volume pattern %s", normalized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool {
|
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type, readOnly bool) bool {
|
||||||
if m.allowAll {
|
if m.allowAll || (m.allowAllReadOnly && readOnly) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if sourceType == mount.TypeTmpfs && source == "" { // backed by memory, exposes nothing from the host, same as --tmpfs
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rules, match := m.named, doublestar.MatchUnvalidated
|
||||||
if isHostVolumeSource(source, sourceType) {
|
if isHostVolumeSource(source, sourceType) {
|
||||||
normalized, err := normalizeHostVolumePath(source)
|
normalized, err := normalizeHostVolumePath(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, g := range m.host {
|
rules, match, source = m.host, doublestar.PathMatchUnvalidated, normalized
|
||||||
if g.Match(normalized) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
for _, g := range m.named {
|
for _, rule := range rules {
|
||||||
if g.Match(source) {
|
if (readOnly || !rule.readOnly) && match(rule.pattern, source) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// needsNoCopy reports a volume only `:ro` rules allow, as dockerd fills an empty volume from the image even when mounted read-only.
|
||||||
|
func (m validVolumeMatcher) needsNoCopy(source string, sourceType mount.Type) bool {
|
||||||
|
return sourceType == mount.TypeVolume && !m.isValid(source, sourceType, false)
|
||||||
|
}
|
||||||
|
|
||||||
func isHostVolumePattern(pattern string) bool {
|
func isHostVolumePattern(pattern string) bool {
|
||||||
return filepath.IsAbs(pattern) ||
|
return filepath.IsAbs(pattern) ||
|
||||||
strings.HasPrefix(pattern, "."+string(filepath.Separator)) ||
|
strings.HasPrefix(pattern, "."+string(filepath.Separator)) ||
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.Netw
|
|||||||
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) NetworkCreate(ctx context.Context, name string, opts mobyclient.NetworkCreateOptions) (mobyclient.NetworkCreateResult, error) {
|
||||||
|
args := m.Called(ctx, name, opts)
|
||||||
|
return args.Get(0).(mobyclient.NetworkCreateResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
||||||
@@ -519,10 +524,12 @@ var _ ExecutionsEnvironment = &containerReference{}
|
|||||||
|
|
||||||
func TestCheckVolumes(t *testing.T) {
|
func TestCheckVolumes(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
desc string
|
desc string
|
||||||
validVolumes []string
|
validVolumes []string
|
||||||
binds []string
|
binds []string
|
||||||
expectedBinds []string
|
expectedBinds []string
|
||||||
|
mounts []mount.Mount
|
||||||
|
expectedMounts []mount.Mount
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
desc: "match all volumes",
|
desc: "match all volumes",
|
||||||
@@ -560,11 +567,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",
|
||||||
},
|
},
|
||||||
@@ -572,8 +582,30 @@ 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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
desc: "read-only rules allow only read-only mounts and copy nothing into volumes",
|
||||||
|
validVolumes: []string{"/etc/ssl:ro", "data:ro", "shared"},
|
||||||
|
binds: []string{"/etc/ssl:/ssl:ro", "/etc/ssl:/ssl", "data:/data:ro", "data:/data", "shared:/shared:ro"},
|
||||||
|
expectedBinds: []string{"/etc/ssl:/ssl:ro", "data:/data:ro,nocopy", "shared:/shared:ro"},
|
||||||
|
mounts: []mount.Mount{
|
||||||
|
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
|
||||||
|
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true, BindOptions: &mount.BindOptions{ReadOnlyNonRecursive: true}},
|
||||||
|
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true},
|
||||||
|
},
|
||||||
|
expectedMounts: []mount.Mount{
|
||||||
|
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
|
||||||
|
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true, VolumeOptions: &mount.VolumeOptions{NoCopy: true}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "read-only wildcard keeps writable rules writable",
|
||||||
|
validVolumes: []string{"**:ro", "shared"},
|
||||||
|
binds: []string{"/etc/ssl:/ssl:ro", "data:/data", "shared:/shared"},
|
||||||
|
expectedBinds: []string{"/etc/ssl:/ssl:ro", "shared:/shared"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.desc, func(t *testing.T) {
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
@@ -584,9 +616,10 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
ValidVolumes: tc.validVolumes,
|
ValidVolumes: tc.validVolumes,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts})
|
||||||
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
||||||
assert.Len(t, hook.AllEntries(), len(tc.binds)-len(tc.expectedBinds)) // every drop is warned about
|
assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts)
|
||||||
|
assert.Len(t, hook.AllEntries(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) // every drop is warned about
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -662,6 +695,9 @@ func TestSanitizeOptionsHostConfig(t *testing.T) {
|
|||||||
DeviceCgroupRules: []string{"a *:* rwm"},
|
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||||
DeviceRequests: []container.DeviceRequest{{Count: -1, Capabilities: [][]string{{"gpu"}}}},
|
DeviceRequests: []container.DeviceRequest{{Count: -1, Capabilities: [][]string{{"gpu"}}}},
|
||||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||||
|
LogConfig: container.LogConfig{Type: "syslog", Config: map[string]string{"syslog-address": "tcp://169.254.169.254:514"}},
|
||||||
|
OomScoreAdj: -1000,
|
||||||
|
OomKillDisable: new(true),
|
||||||
}
|
}
|
||||||
|
|
||||||
sanitizeOptionsHostConfig(logger, hostConfig, &container.HostConfig{})
|
sanitizeOptionsHostConfig(logger, hostConfig, &container.HostConfig{})
|
||||||
@@ -678,6 +714,7 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privilege
|
|||||||
WorkflowOptions: workflowOptions,
|
WorkflowOptions: workflowOptions,
|
||||||
NetworkMode: "bridge",
|
NetworkMode: "bridge",
|
||||||
UsernsMode: "private",
|
UsernsMode: "private",
|
||||||
|
ValidVolumes: []string{"**"},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
|
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
|
||||||
@@ -694,7 +731,9 @@ func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.
|
|||||||
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||||
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||||
"--security-opt apparmor=unconfined --volumes-from other --isolation process " +
|
"--security-opt apparmor=unconfined --volumes-from other --isolation process " +
|
||||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1 " +
|
||||||
|
"--log-driver=syslog --log-opt syslog-address=tcp://169.254.169.254:514 " +
|
||||||
|
"--oom-score-adj=-1000 --oom-kill-disable"
|
||||||
|
|
||||||
// whatever the workflow adds, an unprivileged container comes out exactly as the runner's
|
// whatever the workflow adds, an unprivileged container comes out exactly as the runner's
|
||||||
// own options alone describe it, field for field
|
// own options alone describe it, field for field
|
||||||
@@ -757,6 +796,22 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
|||||||
assert.Empty(t, hostConf.Binds)
|
assert.Empty(t, hostConf.Binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeConfigKeepsTmpfsMounts(t *testing.T) {
|
||||||
|
hostConfig, _ := mergeOptions(t, "", "--mount type=tmpfs,dst=/var/lib/mysql", false)
|
||||||
|
require.Len(t, hostConfig.Mounts, 1)
|
||||||
|
|
||||||
|
for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} {
|
||||||
|
logger, hook := test.NewNullLogger()
|
||||||
|
_, sanitized := (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{Mounts: []mount.Mount{
|
||||||
|
hostConfig.Mounts[0],
|
||||||
|
{Type: mount.TypeVolume, Target: "/anonymous"},
|
||||||
|
{Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"},
|
||||||
|
}})
|
||||||
|
assert.Equal(t, hostConfig.Mounts, sanitized.Mounts)
|
||||||
|
assert.Len(t, hook.AllEntries(), 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContainerInfoFromInspect(t *testing.T) {
|
func TestContainerInfoFromInspect(t *testing.T) {
|
||||||
t.Run("reports no healthcheck when the image declares none", func(t *testing.T) {
|
t.Run("reports no healthcheck when the image declares none", func(t *testing.T) {
|
||||||
info := containerInfoFromInspect(container.InspectResponse{
|
info := containerInfoFromInspect(container.InspectResponse{
|
||||||
@@ -814,12 +869,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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -829,8 +895,10 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
|||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
input: &NewContainerInput{
|
input: &NewContainerInput{
|
||||||
NetworkMode: "bridge",
|
NetworkMode: "bridge",
|
||||||
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
|
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
|
||||||
|
WorkflowOptions: "--volume /unapproved:/var/run/docker.sock",
|
||||||
|
ValidVolumes: []string{"/host/tools"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,6 +920,7 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
|||||||
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
||||||
assert.Zero(t, warnings("", "--shm-size 1g"))
|
assert.Zero(t, warnings("", "--shm-size 1g"))
|
||||||
assert.Equal(t, 1, warnings("--network host", ""))
|
assert.Equal(t, 1, warnings("--network host", ""))
|
||||||
|
assert.Equal(t, 1, warnings("", "--privileged"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -86,6 +91,30 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
func NewDockerNetworkConnectExecutor(_, _ string) common.Executor {
|
||||||
|
return func(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerNetworkDisconnectExecutor(_, _ string) common.Executor {
|
||||||
|
return func(_ context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanNetworks(_ context.Context, _, _ string, _ time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanitizeBinds(_ context.Context, _, binds []string) []string {
|
||||||
|
return binds
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveOrphanJobVolumes(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||||
return nil
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
"github.com/kballard/go-shellquote"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KubernetesOptions configures the pod a job runs in.
|
||||||
|
type KubernetesOptions struct {
|
||||||
|
Kubeconfig string
|
||||||
|
Namespace string
|
||||||
|
PodTemplates []map[string]any // merged in order, a later one winning
|
||||||
|
MaxLifetime time.Duration
|
||||||
|
ForcePull bool
|
||||||
|
RunnerUUID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// KubernetesPod runs a job container and its service containers as one pod.
|
||||||
|
type KubernetesPod struct {
|
||||||
|
options KubernetesOptions
|
||||||
|
client *kubeClient
|
||||||
|
name, secretName string
|
||||||
|
job *kubernetesContainer
|
||||||
|
services []*kubernetesContainer
|
||||||
|
arch, path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type kubernetesContainer struct {
|
||||||
|
LinuxContainerEnvironmentExtensions
|
||||||
|
pod *KubernetesPod
|
||||||
|
id string // the workflow's service id, name is its DNS label form
|
||||||
|
name string
|
||||||
|
input *NewContainerInput
|
||||||
|
probe map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerStatus struct {
|
||||||
|
Name string
|
||||||
|
Ready bool
|
||||||
|
State struct {
|
||||||
|
Waiting *struct{ Reason, Message string }
|
||||||
|
Running *struct{}
|
||||||
|
Terminated *struct {
|
||||||
|
ExitCode int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type podStatus struct {
|
||||||
|
Spec struct{ Containers []struct{} }
|
||||||
|
Status struct {
|
||||||
|
Phase, Reason, Message string
|
||||||
|
Conditions []struct{ Type, Status, Message string }
|
||||||
|
InitContainerStatuses, ContainerStatuses []containerStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKubernetesPod(options KubernetesOptions) (*KubernetesPod, error) {
|
||||||
|
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to Kubernetes: %w", err)
|
||||||
|
}
|
||||||
|
return &KubernetesPod{options: options, client: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobContainer returns the container of the pod the job's steps run in.
|
||||||
|
func (p *KubernetesPod) JobContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||||
|
p.job = &kubernetesContainer{pod: p, name: "job", input: input}
|
||||||
|
return p.job
|
||||||
|
}
|
||||||
|
|
||||||
|
var invalidLabelChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||||
|
|
||||||
|
// KubernetesServiceName returns the container name and hostname of service id, made a DNS label as both must be.
|
||||||
|
func KubernetesServiceName(id string) string {
|
||||||
|
name := strings.Trim(invalidLabelChars.ReplaceAllString(strings.ToLower(id), "-"), "-")
|
||||||
|
return strings.TrimRight(name[:min(63, len(name))], "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceContainer returns a container of the pod the job reaches at hostname KubernetesServiceName(id).
|
||||||
|
func (p *KubernetesPod) ServiceContainer(id string, input *NewContainerInput) ExecutionsEnvironment {
|
||||||
|
service := &kubernetesContainer{pod: p, id: id, name: KubernetesServiceName(id), input: input}
|
||||||
|
p.services = append(p.services, service)
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *KubernetesPod) create(ctx context.Context) error {
|
||||||
|
prefix := strings.Trim(strings.ToLower(p.job.input.Name), "-")
|
||||||
|
prefix = cmp.Or(strings.TrimRight(prefix[:min(40, len(prefix))], "-"), "gitea-runner") + "-"
|
||||||
|
name := prefix + strings.ToLower(rand.Text()[:8]) // chosen here, so cleanup knows it even when a create response is lost
|
||||||
|
labels := map[string]any{"app.kubernetes.io/managed-by": "gitea-runner"}
|
||||||
|
if p.options.RunnerUUID != "" {
|
||||||
|
labels[runnerUUIDLabel] = p.options.RunnerUUID
|
||||||
|
}
|
||||||
|
metadata := map[string]any{"name": name, "labels": labels}
|
||||||
|
if hostname, err := os.Hostname(); p.client.inCluster && err == nil {
|
||||||
|
var runner struct{ Metadata struct{ Name, UID string } }
|
||||||
|
if err := p.client.do(ctx, http.MethodGet, p.client.path("pods/"+hostname), nil, &runner); err == nil {
|
||||||
|
metadata["ownerReferences"] = []any{map[string]any{"apiVersion": "v1", "kind": "Pod", "name": runner.Metadata.Name, "uid": runner.Metadata.UID}}
|
||||||
|
} else {
|
||||||
|
common.Logger(ctx).Debugf("The runner is not pod %s, job pods get no owner: %v", hostname, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secretData := map[string]string{}
|
||||||
|
manifest, err := p.manifest(name, secretData, metadata)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, service := range p.services {
|
||||||
|
if !strings.EqualFold(service.id, service.name) {
|
||||||
|
common.Logger(ctx).Warnf("Service %s is reachable as %s or localhost, as a Kubernetes hostname cannot be %q", service.id, service.name, service.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(secretData) > 0 {
|
||||||
|
p.secretName = name // named like the pod, RemoveOrphanKubernetesResources finds it by that
|
||||||
|
if err := p.client.do(ctx, http.MethodPost, p.client.path("secrets"), map[string]any{"metadata": metadata, "stringData": secretData}, nil); err != nil {
|
||||||
|
return fmt.Errorf("create secret: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.name = name
|
||||||
|
if err := p.client.do(ctx, http.MethodPost, p.client.path("pods"), manifest, nil); err != nil {
|
||||||
|
return fmt.Errorf("create pod: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *KubernetesPod) manifest(secret string, secretData map[string]string, metadata map[string]any) (any, error) {
|
||||||
|
template := map[string]any{"spec": map[string]any{
|
||||||
|
"automountServiceAccountToken": false,
|
||||||
|
"enableServiceLinks": false,
|
||||||
|
"terminationGracePeriodSeconds": 0, // the job container's tail ignores SIGTERM
|
||||||
|
}}
|
||||||
|
for _, layer := range p.options.PodTemplates {
|
||||||
|
template, _ = mergeManifest(template, layer, "").(map[string]any)
|
||||||
|
}
|
||||||
|
templateSpec, _ := template["spec"].(map[string]any)
|
||||||
|
templateContainers, _ := templateSpec["containers"].([]any)
|
||||||
|
templateVolumes, _ := templateSpec["volumes"].([]any)
|
||||||
|
var serviceTemplate any
|
||||||
|
if index := slices.IndexFunc(templateContainers, func(container any) bool { return manifestField(container, "name") == "$services" }); index >= 0 {
|
||||||
|
serviceTemplate = templateContainers[index]
|
||||||
|
templateSpec["containers"] = slices.Delete(slices.Clone(templateContainers), index, index+1)
|
||||||
|
}
|
||||||
|
var containers, hostnames, volumes []any
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, container := range templateContainers {
|
||||||
|
names[manifestField(container, "name")] = true
|
||||||
|
}
|
||||||
|
for _, c := range append([]*kubernetesContainer{p.job}, p.services...) {
|
||||||
|
if c != p.job && (c.name == "" || names[c.name]) {
|
||||||
|
return nil, fmt.Errorf("service %s maps to container %q, which is empty or named like another container of the pod", c.id, c.name)
|
||||||
|
}
|
||||||
|
names[c.name] = true
|
||||||
|
spec, err := c.spec(secret, secretData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c == p.job {
|
||||||
|
containers = append(containers, spec)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
containers = append(containers, mergeManifest(serviceTemplate, spec, ""))
|
||||||
|
hostnames = append(hostnames, c.name)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"workspace", "act"} {
|
||||||
|
if !slices.ContainsFunc(templateVolumes, func(volume any) bool { return manifestField(volume, "name") == name }) {
|
||||||
|
volumes = append(volumes, map[string]any{"name": name, "emptyDir": map[string]any{}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spec := map[string]any{"restartPolicy": "Never", "containers": containers, "volumes": volumes}
|
||||||
|
if len(hostnames) > 0 {
|
||||||
|
spec["hostAliases"] = []any{map[string]any{"ip": "127.0.0.1", "hostnames": hostnames}}
|
||||||
|
}
|
||||||
|
if p.options.MaxLifetime > 0 {
|
||||||
|
spec["activeDeadlineSeconds"] = max(1, int64(p.options.MaxLifetime.Seconds()))
|
||||||
|
}
|
||||||
|
return mergeManifest(template, map[string]any{"apiVersion": "v1", "kind": "Pod", "metadata": metadata, "spec": spec}, ""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeManifest merges over onto base like a strategic merge patch: maps key by key, map list items by name or volumeMounts by mountPath, env entries, volumes and other values replaced.
|
||||||
|
func mergeManifest(base, over any, field string) any {
|
||||||
|
switch over := over.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
merged := map[string]any{}
|
||||||
|
if base, ok := base.(map[string]any); ok {
|
||||||
|
maps.Copy(merged, base)
|
||||||
|
}
|
||||||
|
for key, value := range over {
|
||||||
|
merged[key] = mergeManifest(merged[key], value, key)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
case []any:
|
||||||
|
key := cmp.Or(map[string]string{"volumeMounts": "mountPath"}[field], "name")
|
||||||
|
baseList, _ := base.([]any)
|
||||||
|
merged := slices.Clone(baseList)
|
||||||
|
for _, item := range over {
|
||||||
|
if _, ok := item.(map[string]any); !ok {
|
||||||
|
return over
|
||||||
|
}
|
||||||
|
name := manifestField(item, key)
|
||||||
|
index := slices.IndexFunc(merged, func(existing any) bool { return name != "" && manifestField(existing, key) == name })
|
||||||
|
switch {
|
||||||
|
case index < 0:
|
||||||
|
merged = append(merged, item)
|
||||||
|
case field == "env" || field == "volumes": // each holds one source, merging two makes a pod the API rejects
|
||||||
|
merged[index] = item
|
||||||
|
default:
|
||||||
|
merged[index] = mergeManifest(merged[index], item, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
return over
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestField(item any, key string) string {
|
||||||
|
fields, _ := item.(map[string]any)
|
||||||
|
value, _ := fields[key].(string)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) spec(secret string, secretData map[string]string) (map[string]any, error) {
|
||||||
|
env := []any{}
|
||||||
|
for index, entry := range c.input.Env {
|
||||||
|
key, value, _ := strings.Cut(entry, "=")
|
||||||
|
if c == c.pod.job {
|
||||||
|
env = append(env, map[string]any{"name": key, "value": value})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
secretKey := fmt.Sprintf("%s.%d", c.name, index)
|
||||||
|
secretData[secretKey] = value
|
||||||
|
env = append(env, map[string]any{"name": key, "valueFrom": map[string]any{"secretKeyRef": map[string]any{"name": secret, "key": secretKey}}})
|
||||||
|
}
|
||||||
|
spec := map[string]any{
|
||||||
|
"name": c.name, "image": c.input.Image, "command": escapeExpansion(c.input.Entrypoint), "args": escapeExpansion(c.input.Cmd),
|
||||||
|
"workingDir": c.input.WorkingDir, "env": env,
|
||||||
|
}
|
||||||
|
if c.pod.options.ForcePull {
|
||||||
|
spec["imagePullPolicy"] = "Always"
|
||||||
|
}
|
||||||
|
if c == c.pod.job {
|
||||||
|
spec["volumeMounts"] = []any{
|
||||||
|
map[string]any{"name": "workspace", "mountPath": c.input.WorkingDir},
|
||||||
|
map[string]any{"name": "act", "mountPath": c.GetActPath()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.parseOptions(); err != nil {
|
||||||
|
return nil, fmt.Errorf("service %s: %w", c.name, err)
|
||||||
|
}
|
||||||
|
if c.probe != nil {
|
||||||
|
spec["startupProbe"] = c.probe
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeExpansion keeps kubelet from expanding $(VAR) and $$ in command and args, which docker passes as is.
|
||||||
|
func escapeExpansion(args []string) []string {
|
||||||
|
args = slices.Clone(args)
|
||||||
|
for index := range args {
|
||||||
|
args[index] = strings.ReplaceAll(args[index], "$", "$$")
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOptions turns docker health options, the only ones a pod supports, into a startup probe, which like docker gives up after the retries.
|
||||||
|
func (c *kubernetesContainer) parseOptions() error {
|
||||||
|
args, err := shellquote.Split(c.input.WorkflowOptions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags := pflag.NewFlagSet("options", pflag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
command := flags.String("health-cmd", "", "")
|
||||||
|
interval := flags.Duration("health-interval", 0, "")
|
||||||
|
timeout := flags.Duration("health-timeout", 0, "")
|
||||||
|
retries := flags.Int("health-retries", 0, "")
|
||||||
|
startPeriod := flags.Duration("health-start-period", 0, "")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return fmt.Errorf("options %q: %w, only --health-* options are supported on Kubernetes", c.input.WorkflowOptions, err)
|
||||||
|
}
|
||||||
|
if *command == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
period := max(1, int(cmp.Or(*interval, 30*time.Second).Seconds()))
|
||||||
|
c.probe = map[string]any{
|
||||||
|
"exec": map[string]any{"command": []string{"/bin/sh", "-c", *command}},
|
||||||
|
"periodSeconds": period,
|
||||||
|
"timeoutSeconds": max(1, int(cmp.Or(*timeout, 30*time.Second).Seconds())),
|
||||||
|
"failureThreshold": max(1, cmp.Or(*retries, 3)) + int(startPeriod.Seconds())/period, // docker does not count failures in the start period
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *KubernetesPod) get(ctx context.Context) (*podStatus, error) {
|
||||||
|
var pod podStatus
|
||||||
|
return &pod, p.client.do(ctx, http.MethodGet, p.client.path("pods/"+p.name), nil, &pod)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *KubernetesPod) waitStarted(ctx context.Context) error {
|
||||||
|
var unscheduled string
|
||||||
|
for delay := 100 * time.Millisecond; ; delay = min(2*delay, time.Second) {
|
||||||
|
pod, err := p.get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pod.Status.Phase == "Failed" {
|
||||||
|
return fmt.Errorf("pod %s failed: %s", p.name, cmp.Or(pod.Status.Message, pod.Status.Reason))
|
||||||
|
}
|
||||||
|
started := len(pod.Status.ContainerStatuses) == len(pod.Spec.Containers)
|
||||||
|
for _, status := range append(pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses...) {
|
||||||
|
if waiting := status.State.Waiting; waiting != nil {
|
||||||
|
switch waiting.Reason {
|
||||||
|
case "ErrImagePull", "ImagePullBackOff", "InvalidImageName", "ErrImageNeverPull", "CreateContainerConfigError", "CreateContainerError", "RunContainerError":
|
||||||
|
return fmt.Errorf("container %s of pod %s: %s: %s", status.Name, p.name, waiting.Reason, waiting.Message)
|
||||||
|
}
|
||||||
|
started = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if started {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, condition := range pod.Status.Conditions {
|
||||||
|
if condition.Type == "PodScheduled" && condition.Status == "False" && condition.Message != unscheduled {
|
||||||
|
unscheduled = condition.Message
|
||||||
|
common.Logger(ctx).Infof("Waiting for pod %s to be scheduled: %s", p.name, unscheduled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) Create(_, _ []string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if c != c.pod.job {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.pod.create(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) Start(_ bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if c != c.pod.job {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := c.pod.waitStarted(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `printf '%s\n%s' "$(uname -m)" "$PATH"`}, nil, &stdout, io.Discard); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
machine, path, _ := strings.Cut(stdout.String(), "\n")
|
||||||
|
c.pod.arch, c.pod.path = goArchToActionArch(machine), path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*kubernetesContainer) Pull(bool) common.Executor {
|
||||||
|
return func(context.Context) error { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
shellName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
bashReadonly = []string{"BASHOPTS", "BASH_VERSINFO", "EUID", "PPID", "SHELLOPTS", "UID"} // bash as sh cannot export these
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exec feeds the command to sh on stdin, which exports the env so it stays out of the request URL and, where sh can export a name, out of process arguments.
|
||||||
|
func (c *kubernetesContainer) Exec(command []string, env map[string]string, _, workdir string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
script, args := "", []string{"env", "--"}
|
||||||
|
for name, value := range env {
|
||||||
|
if shellName.MatchString(name) && !slices.Contains(bashReadonly, name) {
|
||||||
|
script += "command export " + shellquote.Join(name+"="+value) + "\n"
|
||||||
|
} else {
|
||||||
|
args = append(args, name+"="+value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
script += "exec " + shellquote.Join(append(args, command...)...)
|
||||||
|
if workdir != "" {
|
||||||
|
script = "cd " + shellquote.Join(workdir) + " || exit\n" + script
|
||||||
|
}
|
||||||
|
defer common.FlushWriter(c.input.Stdout)
|
||||||
|
defer common.FlushWriter(c.input.Stderr)
|
||||||
|
return c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh"}, strings.NewReader(script), c.input.Stdout, c.input.Stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) extract(ctx context.Context, destPath string, write func(io.Writer) error) error {
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
defer reader.Close()
|
||||||
|
go func() { writer.CloseWithError(write(writer)) }()
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `mkdir -p "$1" && tar -xf - -C "$1"`, "sh", destPath}, reader, io.Discard, &stderr); err != nil {
|
||||||
|
return fmt.Errorf("extract to %s: %w: %s", destPath, err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return c.extract(ctx, destPath, func(writer io.Writer) error { return writeFilesTar(ctx, writer, 0, 0, files...) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return c.extract(ctx, destPath, func(writer io.Writer) error {
|
||||||
|
return writeDirTar(ctx, writer, "/", srcPath, useGitIgnore, skipGitDir, 0, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContainerArchive streams the archive, checking the path first as tar writes an empty archive for a missing one.
|
||||||
|
func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `test -e "$1" -o -L "$1" && exec tar -cf - -C "$2" "$3"`, "sh", srcPath, path.Dir(srcPath), path.Base(srcPath)}, nil, writer, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("archive %s: %w: %s", srcPath, err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
writer.CloseWithError(err)
|
||||||
|
}()
|
||||||
|
buffered := bufio.NewReader(reader)
|
||||||
|
if _, err := buffered.Peek(1); err != nil {
|
||||||
|
reader.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return struct {
|
||||||
|
io.Reader
|
||||||
|
io.Closer
|
||||||
|
}{buffered, reader}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||||
|
return parseEnvFile(c, srcPath, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) UpdateFromImageEnv(env *map[string]string) common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
(*env)["PATH"] = cmp.Or((*env)["PATH"], c.pod.path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) Inspect(ctx context.Context) (*Info, error) {
|
||||||
|
if c.pod.name == "" {
|
||||||
|
return nil, fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
|
||||||
|
}
|
||||||
|
pod, err := c.pod.get(ctx)
|
||||||
|
if isNotFound(err) {
|
||||||
|
return nil, fmt.Errorf("pod %s %w", c.pod.name, ErrContainerNotFound)
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info := &Info{ID: c.pod.name + "/" + c.name, State: "created", Health: HealthNone, Ports: map[string]string{}}
|
||||||
|
for port := range c.input.ExposedPorts {
|
||||||
|
info.Ports[port.Port()] = port.Port() // services share the pod's network
|
||||||
|
}
|
||||||
|
for _, status := range pod.Status.ContainerStatuses {
|
||||||
|
if status.Name != c.name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status.State.Running != nil {
|
||||||
|
info.State = StateRunning
|
||||||
|
} else if terminated := status.State.Terminated; terminated != nil {
|
||||||
|
info.State, info.ExitCode, info.HealthOutput = "exited", terminated.ExitCode, terminated.Message
|
||||||
|
}
|
||||||
|
if c.probe != nil {
|
||||||
|
switch {
|
||||||
|
case status.Ready:
|
||||||
|
info.Health = HealthHealthy
|
||||||
|
case info.State == "exited":
|
||||||
|
info.Health = HealthUnhealthy
|
||||||
|
default:
|
||||||
|
info.Health = HealthStarting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) DumpLogs(ctx context.Context) error {
|
||||||
|
if c.pod.name == "" {
|
||||||
|
return fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
|
||||||
|
}
|
||||||
|
defer common.FlushWriter(c.input.Stdout)
|
||||||
|
return c.pod.client.do(ctx, http.MethodGet, c.pod.client.path("pods/"+c.pod.name+"/log?container="+c.name), nil, c.input.Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) Remove() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if c != c.pod.job {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Join(c.pod.client.delete(ctx, "pods", c.pod.name), c.pod.client.delete(ctx, "secrets", c.pod.secretName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveOrphanKubernetesResources removes this runner's job pods created before createdBefore and the Secrets named like them, as listing Secrets would reveal all of the namespace's.
|
||||||
|
func RemoveOrphanKubernetesResources(ctx context.Context, options KubernetesOptions, createdBefore time.Time) error {
|
||||||
|
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to Kubernetes: %w", err)
|
||||||
|
}
|
||||||
|
defer client.http.CloseIdleConnections()
|
||||||
|
var pods struct {
|
||||||
|
Items []struct {
|
||||||
|
Metadata struct {
|
||||||
|
Name string
|
||||||
|
CreationTimestamp time.Time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.do(ctx, http.MethodGet, client.path("pods?labelSelector="+url.QueryEscape(runnerUUIDLabel+"="+options.RunnerUUID)), nil, &pods); err != nil {
|
||||||
|
return fmt.Errorf("list pods: %w", err)
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
for _, item := range pods.Items {
|
||||||
|
if !item.Metadata.CreationTimestamp.Before(createdBefore) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := client.delete(ctx, "secrets", item.Metadata.Name); err != nil {
|
||||||
|
errs = append(errs, err) // leave the pod for the next cleanup, it is the only way back to its Secret
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errs = append(errs, client.delete(ctx, "pods", item.Metadata.Name))
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) Close() common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
c.pod.client.http.CloseIdleConnections()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
|
||||||
|
oldStdout, oldStderr := c.input.Stdout, c.input.Stderr
|
||||||
|
c.input.Stdout, c.input.Stderr = stdout, stderr
|
||||||
|
return oldStdout, oldStderr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubernetesContainer) GetRunnerContext(_ context.Context) map[string]any {
|
||||||
|
return map[string]any{"os": "Linux", "arch": c.pod.arch, "temp": "/tmp", "tool_cache": DefaultToolCache}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount"
|
||||||
|
execProtocol = "v5.channel.k8s.io"
|
||||||
|
)
|
||||||
|
|
||||||
|
type kubeClient struct {
|
||||||
|
server, namespace, token string
|
||||||
|
tokenFile string // re-read on every request, service account tokens rotate
|
||||||
|
inCluster bool
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiError struct {
|
||||||
|
code int
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *apiError) Error() string { return e.message }
|
||||||
|
|
||||||
|
func isNotFound(err error) bool {
|
||||||
|
var apiErr *apiError
|
||||||
|
return errors.As(err, &apiErr) && apiErr.code == http.StatusNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKubeClient(kubeconfig, namespace string) (*kubeClient, error) {
|
||||||
|
client := &kubeClient{namespace: namespace}
|
||||||
|
transport := &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
|
||||||
|
if host := os.Getenv("KUBERNETES_SERVICE_HOST"); host != "" && kubeconfig == "" {
|
||||||
|
if err := client.loadInCluster(host, transport.TLSClientConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if err := client.loadKubeconfig(kubeconfig, transport); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client.http = &http.Client{Transport: transport}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubeClient) loadInCluster(host string, tlsConfig *tls.Config) error {
|
||||||
|
c.server = "https://" + net.JoinHostPort(host, os.Getenv("KUBERNETES_SERVICE_PORT"))
|
||||||
|
c.tokenFile = serviceAccountDir + "/token"
|
||||||
|
ca, err := os.ReadFile(serviceAccountDir + "/ca.crt")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
|
tlsConfig.RootCAs.AppendCertsFromPEM(ca)
|
||||||
|
if c.namespace == "" {
|
||||||
|
namespace, err := os.ReadFile(serviceAccountDir + "/namespace")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.namespace = strings.TrimSpace(string(namespace))
|
||||||
|
}
|
||||||
|
c.inCluster = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubeClient) loadKubeconfig(path string, transport *http.Transport) error {
|
||||||
|
if paths := filepath.SplitList(os.Getenv("KUBECONFIG")); path == "" && len(paths) > 0 {
|
||||||
|
path = paths[0]
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path = filepath.Join(home, ".kube", "config")
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no in-cluster service account and no kubeconfig: %w", err)
|
||||||
|
}
|
||||||
|
var config struct {
|
||||||
|
CurrentContext string `yaml:"current-context"`
|
||||||
|
Contexts []struct {
|
||||||
|
Name string
|
||||||
|
Context struct{ Cluster, User, Namespace string }
|
||||||
|
}
|
||||||
|
Clusters []struct {
|
||||||
|
Name string
|
||||||
|
Cluster struct {
|
||||||
|
Server string
|
||||||
|
TLSServerName string `yaml:"tls-server-name"`
|
||||||
|
InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify"`
|
||||||
|
ProxyURL string `yaml:"proxy-url"`
|
||||||
|
CertificateAuthority string `yaml:"certificate-authority"`
|
||||||
|
CertificateAuthorityData string `yaml:"certificate-authority-data"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Users []struct {
|
||||||
|
Name string
|
||||||
|
User struct {
|
||||||
|
Token string
|
||||||
|
TokenFile string `yaml:"tokenFile"`
|
||||||
|
ClientCertificate string `yaml:"client-certificate"`
|
||||||
|
ClientCertificateData string `yaml:"client-certificate-data"`
|
||||||
|
ClientKey string `yaml:"client-key"`
|
||||||
|
ClientKeyData string `yaml:"client-key-data"`
|
||||||
|
Exec any
|
||||||
|
AuthProvider any `yaml:"auth-provider"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(content, &config); err != nil {
|
||||||
|
return fmt.Errorf("parse kubeconfig %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var current struct{ Cluster, User, Namespace string }
|
||||||
|
for _, entry := range config.Contexts {
|
||||||
|
if entry.Name == config.CurrentContext {
|
||||||
|
current = entry.Context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current.Cluster == "" {
|
||||||
|
return fmt.Errorf("kubeconfig %s has no current context", path)
|
||||||
|
}
|
||||||
|
c.namespace = cmp.Or(c.namespace, current.Namespace, "default")
|
||||||
|
|
||||||
|
resolve := func(file string) string {
|
||||||
|
if file == "" || filepath.IsAbs(file) {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(path), file)
|
||||||
|
}
|
||||||
|
read := func(data, file string) ([]byte, error) {
|
||||||
|
if data != "" {
|
||||||
|
return base64.StdEncoding.DecodeString(data)
|
||||||
|
}
|
||||||
|
if file == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return os.ReadFile(resolve(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := transport.TLSClientConfig
|
||||||
|
for _, cluster := range config.Clusters {
|
||||||
|
if cluster.Name != current.Cluster {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.server = strings.TrimSuffix(cluster.Cluster.Server, "/")
|
||||||
|
tlsConfig.ServerName, tlsConfig.InsecureSkipVerify = cluster.Cluster.TLSServerName, cluster.Cluster.InsecureSkipTLSVerify
|
||||||
|
if cluster.Cluster.ProxyURL != "" {
|
||||||
|
proxy, err := url.Parse(cluster.Cluster.ProxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("kubeconfig %s: %w", path, err)
|
||||||
|
}
|
||||||
|
transport.Proxy = http.ProxyURL(proxy)
|
||||||
|
}
|
||||||
|
ca, err := read(cluster.Cluster.CertificateAuthorityData, cluster.Cluster.CertificateAuthority)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ca != nil {
|
||||||
|
tlsConfig.RootCAs = x509.NewCertPool()
|
||||||
|
tlsConfig.RootCAs.AppendCertsFromPEM(ca)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, user := range config.Users {
|
||||||
|
if user.Name != current.User {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if user.User.Exec != nil || user.User.AuthProvider != nil {
|
||||||
|
return fmt.Errorf("kubeconfig %s: user %q authenticates through a credential plugin, which the runner does not support, use a token or client certificate", path, user.Name)
|
||||||
|
}
|
||||||
|
c.token, c.tokenFile = user.User.Token, resolve(user.User.TokenFile)
|
||||||
|
cert, err := read(user.User.ClientCertificateData, user.User.ClientCertificate)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key, err := read(user.User.ClientKeyData, user.User.ClientKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cert != nil {
|
||||||
|
pair, err := tls.X509KeyPair(cert, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tlsConfig.Certificates = []tls.Certificate{pair}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.server == "" {
|
||||||
|
return fmt.Errorf("kubeconfig %s names no server for context %q", path, config.CurrentContext)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubeClient) authorize(header http.Header) error {
|
||||||
|
token := c.token
|
||||||
|
if c.tokenFile != "" {
|
||||||
|
content, err := os.ReadFile(c.tokenFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(string(content))
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubeClient) path(resource string) string {
|
||||||
|
return "/api/v1/namespaces/" + c.namespace + "/" + resource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubeClient) do(ctx context.Context, method, path string, in, out any) error {
|
||||||
|
var body io.Reader
|
||||||
|
if in != nil {
|
||||||
|
content, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(content)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.server+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.authorize(req.Header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return statusError(resp)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = io.Discard // draining lets the connection be reused
|
||||||
|
}
|
||||||
|
if writer, ok := out.(io.Writer); ok {
|
||||||
|
_, err = io.Copy(writer, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubeClient) delete(ctx context.Context, resource, name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := c.do(ctx, http.MethodDelete, c.path(resource+"/"+name), nil, nil); err != nil && !isNotFound(err) {
|
||||||
|
return fmt.Errorf("delete %s %s: %w", resource, name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusError(resp *http.Response) error {
|
||||||
|
content, _ := io.ReadAll(resp.Body)
|
||||||
|
var status struct{ Message string }
|
||||||
|
if json.Unmarshal(content, &status) != nil || status.Message == "" {
|
||||||
|
status.Message = strings.TrimSpace(string(content))
|
||||||
|
}
|
||||||
|
return &apiError{code: resp.StatusCode, message: status.Message}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *kubeClient) exec(ctx context.Context, pod, containerName string, command []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
query := url.Values{"container": {containerName}, "command": command, "stdout": {"true"}, "stderr": {"true"}}
|
||||||
|
if stdin != nil {
|
||||||
|
query.Set("stdin", "true")
|
||||||
|
}
|
||||||
|
header := http.Header{}
|
||||||
|
if err := c.authorize(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancelCause(ctx)
|
||||||
|
defer cancel(nil)
|
||||||
|
conn, resp, err := websocket.Dial(ctx, c.server+c.path("pods/"+pod+"/exec?")+query.Encode(), &websocket.DialOptions{
|
||||||
|
HTTPClient: c.http,
|
||||||
|
HTTPHeader: header,
|
||||||
|
Subprotocols: []string{execProtocol},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if resp != nil && resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
err = statusError(resp)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("exec in pod %s: %w", pod, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.CloseNow() }()
|
||||||
|
if conn.Subprotocol() != execProtocol {
|
||||||
|
return fmt.Errorf("the Kubernetes API server does not speak %s, which needs Kubernetes 1.30 or later", execProtocol)
|
||||||
|
}
|
||||||
|
conn.SetReadLimit(-1)
|
||||||
|
|
||||||
|
if stdin != nil {
|
||||||
|
go func() {
|
||||||
|
if err := sendStdin(ctx, conn, stdin); err != nil {
|
||||||
|
cancel(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if conn.Ping(ctx) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, frame, err := conn.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return cmp.Or(context.Cause(ctx), fmt.Errorf("exec in pod %s ended without an exit status: %w", pod, err))
|
||||||
|
}
|
||||||
|
if len(frame) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch frame[0] {
|
||||||
|
case 1:
|
||||||
|
_, err = stdout.Write(frame[1:])
|
||||||
|
case 2:
|
||||||
|
_, err = stderr.Write(frame[1:])
|
||||||
|
case 3:
|
||||||
|
return cmp.Or(context.Cause(ctx), execStatus(frame[1:]))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendStdin(ctx context.Context, conn *websocket.Conn, stdin io.Reader) error {
|
||||||
|
frame := make([]byte, 1+32*1024) // frame[0] is the stdin channel, 0
|
||||||
|
for {
|
||||||
|
n, err := stdin.Read(frame[1:])
|
||||||
|
if n > 0 {
|
||||||
|
if err := conn.Write(ctx, websocket.MessageBinary, frame[:1+n]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return conn.Write(ctx, websocket.MessageBinary, []byte{255, 0}) // v5 close signal for stdin
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func execStatus(content []byte) error {
|
||||||
|
var status struct {
|
||||||
|
Status, Message string
|
||||||
|
Details struct {
|
||||||
|
Causes []struct{ Reason, Message string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, &status); err != nil {
|
||||||
|
return fmt.Errorf("decode exec status: %w", err)
|
||||||
|
}
|
||||||
|
if status.Status == "Success" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, cause := range status.Details.Causes {
|
||||||
|
if code, err := strconv.Atoi(cause.Message); cause.Reason == "ExitCode" && err == nil {
|
||||||
|
return ExitCodeError(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("exec failed: %s", status.Message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type podManifest struct {
|
||||||
|
Metadata struct{ Labels map[string]string }
|
||||||
|
Spec struct {
|
||||||
|
AutomountServiceAccountToken bool
|
||||||
|
EnableServiceLinks bool
|
||||||
|
HostAliases []struct{ Hostnames []string }
|
||||||
|
Volumes []map[string]any
|
||||||
|
Containers []struct {
|
||||||
|
Name, ImagePullPolicy string
|
||||||
|
Resources map[string]any
|
||||||
|
StartupProbe *struct{ PeriodSeconds, TimeoutSeconds, FailureThreshold int }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeCluster struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
requests map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCluster) record(request, content string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.requests[request] = append(f.requests[request], content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeKubernetes(t *testing.T, podStatus string) (string, *fakeCluster) {
|
||||||
|
cluster := &fakeCluster{requests: map[string][]string{}}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
cluster.record(r.Method+" "+r.URL.Path, string(body))
|
||||||
|
pods, secrets := "/api/v1/namespaces/ci/pods", "/api/v1/namespaces/ci/secrets"
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == pods:
|
||||||
|
cluster.record("list", r.URL.Query().Get("labelSelector"))
|
||||||
|
_, _ = io.WriteString(w, `{"items":[{"metadata":{"name":"old","creationTimestamp":"2020-01-01T00:00:00Z"}},{"metadata":{"name":"new","creationTimestamp":"2999-01-01T00:00:00Z"}}]}`)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/exec"):
|
||||||
|
cluster.record("exec", r.URL.RawQuery)
|
||||||
|
serveLocalExec(t, w, r)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/log"):
|
||||||
|
_, _ = io.WriteString(w, "log of "+r.URL.Query().Get("container"))
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, pods+"/"):
|
||||||
|
_, _ = io.WriteString(w, podStatus)
|
||||||
|
case r.Method == http.MethodPost && (r.URL.Path == pods || r.URL.Path == secrets), r.Method == http.MethodDelete:
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
kubeconfig := filepath.Join(t.TempDir(), "kubeconfig")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(kubeconfig), "token"), []byte("test-token\n"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(kubeconfig, fmt.Appendf(nil, `
|
||||||
|
current-context: test
|
||||||
|
contexts: [{name: test, context: {cluster: test, user: test, namespace: ci}}]
|
||||||
|
clusters: [{name: test, cluster: {server: "http://kubernetes.invalid", proxy-url: %q}}]
|
||||||
|
users: [{name: test, user: {tokenFile: token}}]
|
||||||
|
`, server.URL), 0o600))
|
||||||
|
return kubeconfig, cluster
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveLocalExec(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{execProtocol}})
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.CloseNow() }()
|
||||||
|
conn.SetReadLimit(-1)
|
||||||
|
command := r.URL.Query()["command"]
|
||||||
|
var stdin bytes.Buffer
|
||||||
|
for r.URL.Query().Get("stdin") == "true" {
|
||||||
|
_, frame, err := conn.Read(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if frame[0] == 255 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
stdin.Write(frame[1:])
|
||||||
|
}
|
||||||
|
cmd := exec.Command(command[0], command[1:]...)
|
||||||
|
cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = &stdin, &stdout, &stderr
|
||||||
|
status := `{"status":"Success"}`
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if err := cmd.Run(); errors.As(err, &exitErr) {
|
||||||
|
status = fmt.Sprintf(`{"status":"Failure","reason":"NonZeroExitCode","details":{"causes":[{"reason":"ExitCode","message":"%d"}]}}`, exitErr.ExitCode())
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
for _, frame := range [][]byte{append([]byte{1}, stdout.Bytes()...), append([]byte{2}, stderr.Bytes()...), append([]byte{3}, status...)} {
|
||||||
|
_ = conn.Write(r.Context(), websocket.MessageBinary, frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("uses POSIX shell")
|
||||||
|
}
|
||||||
|
ctx := t.Context()
|
||||||
|
kubeconfig, cluster := fakeKubernetes(t, `{"spec":{"containers":[{},{}]},"status":{"phase":"Running","containerStatuses":[
|
||||||
|
{"name":"job","ready":true,"state":{"running":{}}},
|
||||||
|
{"name":"my-db","ready":true,"state":{"running":{}}}]}}`)
|
||||||
|
workdir, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
options := KubernetesOptions{Kubeconfig: kubeconfig, RunnerUUID: "runner-uuid", PodTemplates: []map[string]any{
|
||||||
|
{"spec": map[string]any{
|
||||||
|
"automountServiceAccountToken": true,
|
||||||
|
"containers": []any{
|
||||||
|
map[string]any{"name": "job", "resources": map[string]any{"limits": map[string]any{"cpu": "2"}}},
|
||||||
|
map[string]any{
|
||||||
|
"name": "$services", "imagePullPolicy": "Always", "resources": map[string]any{"limits": map[string]any{"memory": "256Mi"}},
|
||||||
|
"env": []any{map[string]any{"name": "POSTGRES_PASSWORD", "value": "template"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
{"spec": map[string]any{
|
||||||
|
"volumes": []any{map[string]any{"name": "workspace", "emptyDir": map[string]any{"sizeLimit": "1Gi"}}},
|
||||||
|
"containers": []any{map[string]any{"name": "job", "resources": map[string]any{"limits": map[string]any{"memory": "1Gi"}}}},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
pod, err := NewKubernetesPod(options)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var jobLog, serviceLog bytes.Buffer
|
||||||
|
job := pod.JobContainer(&NewContainerInput{Name: "Job-Name", Image: "node", WorkingDir: workdir, Stdout: &jobLog, Stderr: &jobLog})
|
||||||
|
service := pod.ServiceContainer("My_DB", &NewContainerInput{
|
||||||
|
Image: "postgres",
|
||||||
|
Cmd: []string{"postgres", "-D", "$(PGDATA)"},
|
||||||
|
Env: []string{"POSTGRES_PASSWORD=service-secret"},
|
||||||
|
WorkflowOptions: "--health-cmd pg_isready --health-interval 2s --health-start-period 4s",
|
||||||
|
Stdout: &serviceLog,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, job.Create(nil, nil)(ctx))
|
||||||
|
var manifest podManifest
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(cluster.requests["POST /api/v1/namespaces/ci/pods"][0]), &manifest))
|
||||||
|
assert.Equal(t, "gitea-runner", manifest.Metadata.Labels["app.kubernetes.io/managed-by"])
|
||||||
|
assert.Equal(t, "runner-uuid", manifest.Metadata.Labels[runnerUUIDLabel])
|
||||||
|
assert.True(t, manifest.Spec.AutomountServiceAccountToken)
|
||||||
|
assert.False(t, manifest.Spec.EnableServiceLinks)
|
||||||
|
assert.Equal(t, []string{"my-db"}, manifest.Spec.HostAliases[0].Hostnames)
|
||||||
|
require.Len(t, manifest.Spec.Containers, 2)
|
||||||
|
assert.Equal(t, map[string]any{"limits": map[string]any{"cpu": "2", "memory": "1Gi"}}, manifest.Spec.Containers[0].Resources)
|
||||||
|
assert.Empty(t, manifest.Spec.Containers[0].ImagePullPolicy)
|
||||||
|
assert.Equal(t, "my-db", manifest.Spec.Containers[1].Name)
|
||||||
|
assert.Equal(t, "Always", manifest.Spec.Containers[1].ImagePullPolicy)
|
||||||
|
assert.Equal(t, map[string]any{"limits": map[string]any{"memory": "256Mi"}}, manifest.Spec.Containers[1].Resources)
|
||||||
|
assert.Equal(t, []map[string]any{
|
||||||
|
{"name": "workspace", "emptyDir": map[string]any{"sizeLimit": "1Gi"}},
|
||||||
|
{"name": "act", "emptyDir": map[string]any{}},
|
||||||
|
}, manifest.Spec.Volumes)
|
||||||
|
assert.Equal(t, struct{ PeriodSeconds, TimeoutSeconds, FailureThreshold int }{2, 30, 5}, *manifest.Spec.Containers[1].StartupProbe)
|
||||||
|
assert.NotContains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], "service-secret")
|
||||||
|
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/secrets"][0], "service-secret")
|
||||||
|
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/secrets"][0], `"com.gitea.runner.uuid":"runner-uuid"`)
|
||||||
|
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], `{"name":"POSTGRES_PASSWORD","valueFrom":{"secretKeyRef":{"key":"my-db.0","name":"`+pod.secretName+`"}}}`)
|
||||||
|
assert.Equal(t, []any{"new"}, mergeManifest([]any{"old"}, []any{"new"}, ""))
|
||||||
|
mount := func(path string) any { return map[string]any{"name": "config", "mountPath": path} }
|
||||||
|
assert.Equal(t, []any{mount("/a"), mount("/b")}, mergeManifest([]any{mount("/a")}, []any{mount("/b")}, "volumeMounts"))
|
||||||
|
tmpfs := map[string]any{"name": "workspace", "emptyDir": map[string]any{"medium": "Memory"}}
|
||||||
|
ephemeral := map[string]any{"name": "workspace", "ephemeral": map[string]any{}}
|
||||||
|
assert.Equal(t, []any{ephemeral}, mergeManifest([]any{tmpfs}, []any{ephemeral}, "volumes"))
|
||||||
|
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], `"args":["postgres","-D","$$(PGDATA)"]`)
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workdir, "env"), []byte("#!/bin/sh\necho \"$@\" > \"$0-args\"\nexec /usr/bin/env \"$@\"\n"), 0o755))
|
||||||
|
t.Setenv("PATH", workdir+":"+os.Getenv("PATH"))
|
||||||
|
require.NoError(t, job.Start(false)(ctx))
|
||||||
|
|
||||||
|
stepEnv := map[string]string{"INPUT_WHO-TO-GREET": "it's\nme", "SECRET": "hunter2", "UID": "5"}
|
||||||
|
require.NoError(t, job.Exec([]string{"printenv", "SECRET"}, stepEnv, "", "")(ctx))
|
||||||
|
envArgs, err := os.ReadFile(filepath.Join(workdir, "env-args"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(envArgs), "hunter2")
|
||||||
|
require.NoError(t, job.Exec([]string{"printenv", "INPUT_WHO-TO-GREET"}, stepEnv, "", "")(ctx))
|
||||||
|
require.NoError(t, job.Exec([]string{"printenv", "UID"}, stepEnv, "", "")(ctx))
|
||||||
|
require.NoError(t, job.Exec([]string{"pwd", "-P"}, nil, "", workdir)(ctx))
|
||||||
|
assert.Equal(t, "hunter2\nit's\nme\n5\n"+workdir+"\n", jobLog.String())
|
||||||
|
assert.Equal(t, ExitCodeError(3), job.Exec([]string{"sh", "-c", "echo failing >&2; exit 3"}, nil, "", "")(ctx))
|
||||||
|
assert.Contains(t, jobLog.String(), "failing\n")
|
||||||
|
for _, query := range cluster.requests["exec"] {
|
||||||
|
assert.NotContains(t, query, "hunter2")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, job.Copy(workdir+"/act/", &FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: `{"ref":"main"}`})(ctx))
|
||||||
|
archive, err := job.GetContainerArchive(ctx, workdir+"/act/workflow/event.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
reader := tar.NewReader(archive)
|
||||||
|
header, err := reader.Next()
|
||||||
|
require.NoError(t, err)
|
||||||
|
content, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "event.json", header.Name)
|
||||||
|
assert.JSONEq(t, `{"ref":"main"}`, string(content))
|
||||||
|
require.NoError(t, archive.Close())
|
||||||
|
_, err = job.GetContainerArchive(ctx, workdir+"/missing")
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
actionDir := filepath.Join(t.TempDir(), "action")
|
||||||
|
require.NoError(t, os.MkdirAll(actionDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(actionDir, "index.js"), []byte("main()"), 0o644))
|
||||||
|
require.NoError(t, job.CopyDir(workdir+"/act/actions/sha/", actionDir+"/", false, false)(ctx))
|
||||||
|
script, err := os.ReadFile(workdir + "/act/actions/sha/index.js")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "main()", string(script))
|
||||||
|
assert.Contains(t, cluster.requests["exec"][len(cluster.requests["exec"])-1], "command="+url.QueryEscape(workdir+"/act/actions/sha/"))
|
||||||
|
|
||||||
|
pathEnv := map[string]string{}
|
||||||
|
require.NoError(t, job.UpdateFromImageEnv(&pathEnv)(ctx))
|
||||||
|
assert.Equal(t, os.Getenv("PATH"), pathEnv["PATH"])
|
||||||
|
|
||||||
|
info, err := service.Inspect(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, HealthHealthy, info.Health)
|
||||||
|
require.NoError(t, service.DumpLogs(ctx))
|
||||||
|
assert.Equal(t, "log of my-db", serviceLog.String())
|
||||||
|
|
||||||
|
require.NoError(t, job.Remove()(ctx))
|
||||||
|
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/pods/"+pod.name], 1)
|
||||||
|
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/"+pod.secretName], 1)
|
||||||
|
|
||||||
|
require.NoError(t, RemoveOrphanKubernetesResources(ctx, options, time.Now()))
|
||||||
|
assert.Equal(t, []string{runnerUUIDLabel + "=runner-uuid"}, cluster.requests["list"])
|
||||||
|
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/pods/old"], 1)
|
||||||
|
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/old"], 1)
|
||||||
|
assert.Empty(t, cluster.requests["DELETE /api/v1/namespaces/ci/pods/new"])
|
||||||
|
assert.Empty(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/new"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKubernetesPodFailsBeforeSteps(t *testing.T) {
|
||||||
|
kubeconfig, _ := fakeKubernetes(t, `{"spec":{"containers":[{}]},"status":{"containerStatuses":[
|
||||||
|
{"name":"job","state":{"waiting":{"reason":"ErrImagePull","message":"no such image"}}}]}}`)
|
||||||
|
template := map[string]any{"spec": map[string]any{"containers": []any{map[string]any{"name": "sidecar"}}}}
|
||||||
|
for _, test := range []struct {
|
||||||
|
services []string
|
||||||
|
options, err string
|
||||||
|
}{
|
||||||
|
{[]string{"db"}, "--hostname db", "only --health-* options are supported"},
|
||||||
|
{[]string{"sidecar"}, "", "named like another container"},
|
||||||
|
{[]string{"my_db", "My-DB"}, "", `service My-DB maps to container "my-db"`},
|
||||||
|
} {
|
||||||
|
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig, Namespace: "ci", PodTemplates: []map[string]any{template}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
job := pod.JobContainer(&NewContainerInput{Image: "node"})
|
||||||
|
for _, service := range test.services {
|
||||||
|
pod.ServiceContainer(service, &NewContainerInput{Image: "postgres", WorkflowOptions: test.options})
|
||||||
|
}
|
||||||
|
require.ErrorContains(t, job.Create(nil, nil)(t.Context()), test.err)
|
||||||
|
}
|
||||||
|
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig, Namespace: "ci"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
job := pod.JobContainer(&NewContainerInput{Image: "missing"})
|
||||||
|
require.NoError(t, job.Create(nil, nil)(t.Context()))
|
||||||
|
require.ErrorContains(t, job.Start(false)(t.Context()), "ErrImagePull: no such image")
|
||||||
|
|
||||||
|
plugin := filepath.Join(t.TempDir(), "kubeconfig")
|
||||||
|
require.NoError(t, os.WriteFile(plugin, []byte(`
|
||||||
|
current-context: eks
|
||||||
|
contexts: [{name: eks, context: {cluster: eks, user: eks}}]
|
||||||
|
clusters: [{name: eks, cluster: {server: "https://eks.example"}}]
|
||||||
|
users: [{name: eks, user: {exec: {command: aws}}}]
|
||||||
|
`), 0o600))
|
||||||
|
_, err = NewKubernetesPod(KubernetesOptions{Kubeconfig: plugin})
|
||||||
|
require.ErrorContains(t, err, "credential plugin")
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
|
||||||
|
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||||
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
|
||||||
|
tw := tar.NewWriter(w)
|
||||||
|
for _, file := range files {
|
||||||
|
common.Logger(ctx).Debugf("Writing entry to tarball %s len:%d", file.Name, len(file.Body))
|
||||||
|
if err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: file.Name,
|
||||||
|
Mode: file.Mode,
|
||||||
|
Size: int64(len(file.Body)),
|
||||||
|
Uid: uid,
|
||||||
|
Gid: gid,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tw.Write([]byte(file.Body)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDirTar prefixes entries with dstPath for extraction at root.
|
||||||
|
func writeDirTar(ctx context.Context, w io.Writer, dstPath, srcPath string, useGitIgnore, skipGitDir bool, uid, gid int) error {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
tw := tar.NewWriter(w)
|
||||||
|
srcPrefix := strings.TrimSuffix(filepath.Dir(srcPath), string(filepath.Separator)) + string(filepath.Separator)
|
||||||
|
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
||||||
|
|
||||||
|
var ignorer gitignore.Matcher
|
||||||
|
if useGitIgnore {
|
||||||
|
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("Error loading .gitignore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ignorer = gitignore.NewMatcher(ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := filepath.Walk(srcPath, (&filecollector.FileCollector{
|
||||||
|
Ignorer: ignorer,
|
||||||
|
SrcPath: srcPath,
|
||||||
|
SrcPrefix: srcPrefix,
|
||||||
|
SkipGitDir: skipGitDir,
|
||||||
|
Handler: &filecollector.TarCollector{TarWriter: tw, UID: uid, GID: gid, DstDir: dstPath[1:]},
|
||||||
|
}).CollectFiles(ctx, []string{})); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tw.Close()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
+38
-37
@@ -6,6 +6,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -286,11 +288,30 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin
|
|||||||
return strings.ToLower(image)
|
return strings.ToLower(image)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One image per step and job: its stages share a build, and concurrent jobs never run each other's.
|
||||||
|
func (rc *RunContext) dockerActionImage(ctx context.Context, step actionStep, actionName string, localAction bool) string {
|
||||||
|
job := rc.topLevelRunContext()
|
||||||
|
if job.dockerActionImages == nil {
|
||||||
|
job.dockerActionImages = map[*model.Step]string{}
|
||||||
|
}
|
||||||
|
image, ok := job.dockerActionImages[step.getStepModel()]
|
||||||
|
if !ok {
|
||||||
|
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction) + "-" + strings.ToLower(rand.Text())
|
||||||
|
if !common.Dryrun(ctx) { // a dry run builds nothing to remove
|
||||||
|
job.dockerActionImages[step.getStepModel()] = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: break out parts of function to reduce complexicity
|
// TODO: break out parts of function to reduce complexicity
|
||||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
|
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
|
if rc.kubernetes {
|
||||||
|
return errKubernetesDocker
|
||||||
|
}
|
||||||
|
|
||||||
var prepImage common.Executor
|
var prepImage common.Executor
|
||||||
var image string
|
var image string
|
||||||
@@ -300,7 +321,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
// Apply forcePull only for prebuild docker images
|
// Apply forcePull only for prebuild docker images
|
||||||
forcePull = rc.Config.ForcePull
|
forcePull = rc.Config.ForcePull
|
||||||
} else {
|
} else {
|
||||||
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction)
|
image = rc.dockerActionImage(ctx, step, actionName, localAction)
|
||||||
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
||||||
|
|
||||||
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
||||||
@@ -353,12 +374,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 +391,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 +407,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 +419,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 +434,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,40 +444,33 @@ 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)
|
||||||
for k, v := range *step.getEnv() {
|
for k, v := range *step.getEnv() {
|
||||||
|
if rc.IsHostEnv() && strings.EqualFold(k, rc.JobContainer.GetPathVariableName()) && v == rc.JobContainer.DefaultPathVariable() { // the runner's own PATH stays out, as on GitHub
|
||||||
|
continue
|
||||||
|
}
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
|
|
||||||
binds, mounts, err := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts(ctx)
|
||||||
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 +494,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) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ee := parent.NewActionInputsExpressionEvaluator(ctx, step)
|
ee := parent.NewStepExpressionEvaluator(ctx, step)
|
||||||
|
|
||||||
for inputID, input := range step.getActionModel().Inputs {
|
for inputID, input := range step.getActionModel().Inputs {
|
||||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||||
@@ -104,6 +104,9 @@ 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,
|
||||||
|
kubernetes: parent.kubernetes,
|
||||||
|
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
|
||||||
|
}
|
||||||
+14
-10
@@ -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) {
|
||||||
@@ -520,6 +513,17 @@ func TestDockerActionImageTag(t *testing.T) {
|
|||||||
dockerActionImageTag("owner/repo", "./", true),
|
dockerActionImageTag("owner/repo", "./", true),
|
||||||
dockerActionImageTag("owner/repo", "./sub", true),
|
dockerActionImageTag("owner/repo", "./sub", true),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
workflow := &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}
|
||||||
|
job := newTestRC(workflow, nil)
|
||||||
|
step := &stepActionRemote{Step: &model.Step{}, RunContext: job}
|
||||||
|
image := job.dockerActionImage(t.Context(), step, "abc123", false)
|
||||||
|
assert.True(t, strings.HasPrefix(image, "act-abc123-dockeraction:latest-"))
|
||||||
|
assert.Equal(t, image, (&RunContext{Parent: job}).dockerActionImage(t.Context(), step, "abc123", false))
|
||||||
|
assert.NotEqual(t, image, job.dockerActionImage(t.Context(), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false))
|
||||||
|
assert.NotEqual(t, image, newTestRC(workflow, nil).dockerActionImage(t.Context(), step, "abc123", false))
|
||||||
|
job.dockerActionImage(common.WithDryrun(t.Context(), true), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false)
|
||||||
|
assert.Len(t, job.dockerActionImages, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
func TestExecAsDockerStageEntrypoint(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() {
|
||||||
|
|||||||
@@ -49,12 +49,17 @@ func TestDockerProxyMountedJob(t *testing.T) {
|
|||||||
ContainerNamePrefix: resourceName,
|
ContainerNamePrefix: resourceName,
|
||||||
ContainerDaemonSocket: dockerClient.DaemonHost(),
|
ContainerDaemonSocket: dockerClient.DaemonHost(),
|
||||||
ContainerMaxLifetime: 2 * time.Minute,
|
ContainerMaxLifetime: 2 * time.Minute,
|
||||||
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode},
|
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)
|
require.NoError(t, err)
|
||||||
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
|
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
plan, err := planner.PlanEvent("push")
|
plan, err := planner.PlanEvent("push")
|
||||||
|
if mode == "direct" {
|
||||||
|
plan, err = planner.PlanJob("proxy")
|
||||||
|
}
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
|
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -85,6 +90,9 @@ func TestDockerProxyMountedJob(t *testing.T) {
|
|||||||
messages = append(messages, strings.TrimSpace(entry.Message))
|
messages = append(messages, strings.TrimSpace(entry.Message))
|
||||||
}
|
}
|
||||||
require.Contains(t, messages, "docker proxy post verified")
|
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{})
|
_, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{})
|
||||||
assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err)
|
assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err)
|
||||||
_, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{})
|
_, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{})
|
||||||
|
|||||||
+55
-99
@@ -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)),
|
||||||
@@ -280,50 +246,44 @@ func inputsFromEnv(env map[string]string) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
||||||
|
if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow
|
||||||
|
return maps.Clone(stepInputs)
|
||||||
|
}
|
||||||
inputs := map[string]any{}
|
inputs := map[string]any{}
|
||||||
|
|
||||||
maps.Copy(inputs, rc.workflowCallInputs)
|
// only a called workflow's job itself takes the inputs Gitea resolved,
|
||||||
maps.Copy(inputs, stepInputs)
|
// composite actions copy its Config but have their own `inputs`
|
||||||
|
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
|
||||||
if ghc.EventName == "workflow_dispatch" {
|
maps.Copy(inputs, rc.Config.WorkflowCallInputs)
|
||||||
config := rc.Run.Workflow.WorkflowDispatchConfig()
|
return inputs
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ghc.EventName == "workflow_call" {
|
maps.Copy(inputs, rc.workflowCallInputs)
|
||||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
|
||||||
if config != nil && config.Inputs != nil {
|
switch ghc.EventName {
|
||||||
for k, v := range config.Inputs {
|
case "workflow_dispatch":
|
||||||
value := nestedMapLookup(ghc.Event, "inputs", k)
|
if config := rc.Run.Workflow.WorkflowDispatchConfig(); config != nil {
|
||||||
if value == nil {
|
for name, input := range config.Inputs {
|
||||||
value = v.Default
|
if input.Type == "number" {
|
||||||
|
input.Type = "string" // boolean is the only typed dispatch input, as in Gitea
|
||||||
}
|
}
|
||||||
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 {
|
||||||
|
setEvaluatorInput(inputs, ghc, name, input)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 +291,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,123 @@ 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 TestGetEvaluatorInputsResolvedByGitea(t *testing.T) {
|
||||||
|
wf, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
name: {type: string, default: callee}
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
name: {type: string}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}}
|
||||||
|
ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}}
|
||||||
|
|
||||||
|
// the event's own inputs must not override those Gitea resolved
|
||||||
|
job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}}
|
||||||
|
assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc))
|
||||||
|
|
||||||
|
composite := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{}}, Parent: job}
|
||||||
|
assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionInputsShadowDispatchInputs(t *testing.T) {
|
||||||
|
wf, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
message: {type: string}
|
||||||
|
jobs:
|
||||||
|
job1: {}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
rc := newTestRC(wf, nil)
|
||||||
|
rc.Config.EventName = "workflow_dispatch"
|
||||||
|
rc.EventJSON = `{"inputs":{"message":"workflow"}}`
|
||||||
|
step := &stepActionRemote{RunContext: rc, env: map[string]string{"INPUT_MESSAGE": "step"}}
|
||||||
|
assert.Equal(t, "step", rc.NewActionInputsExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
|
||||||
|
assert.Equal(t, "workflow", rc.NewStepExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
|
||||||
|
enabled, err := isStepEnabled(t.Context(), "inputs.message == 'workflow'", step, stepStageMain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, enabled)
|
||||||
|
step.Step, step.action = &model.Step{}, &model.Action{Inputs: map[string]model.Input{"fallback": {Default: "${{ inputs.message }}"}}}
|
||||||
|
env, err := evaluateCompositeInputAndEnv(t.Context(), rc, step)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "workflow", env["INPUT_FALLBACK"])
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -40,6 +41,16 @@ func requireDocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requireDockerSharingFiles(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
requireDocker(t)
|
||||||
|
for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} {
|
||||||
|
if _, err := os.Stat(marker); err == nil {
|
||||||
|
t.Skip("skipping: inside a container, the docker daemon cannot bind-mount this process's files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
||||||
// self-hosted (host environment) suite, which runs steps directly on the host.
|
// self-hosted (host environment) suite, which runs steps directly on the host.
|
||||||
func requireHostTools(t *testing.T, tools ...string) {
|
func requireHostTools(t *testing.T, tools ...string) {
|
||||||
|
|||||||
+53
-34
@@ -13,12 +13,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -259,6 +259,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||||
}
|
}
|
||||||
setJobResult(ctx, info, rc, jobError == nil)
|
setJobResult(ctx, info, rc, jobError == nil)
|
||||||
|
rc.removeDockerActionImages(ctx)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -282,8 +283,8 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
// 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.
|
||||||
@@ -291,10 +292,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:
|
||||||
@@ -414,41 +412,53 @@ 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.
|
// evaluateJobEnv returns the workflow and job env evaluated, as far as it got on error, leaving rc.Env raw.
|
||||||
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
func evaluateJobEnv(ctx context.Context, rc *RunContext) (map[string]string, error) {
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
env := rc.GetEnv()
|
||||||
var err error
|
evaluated := maps.Clone(env)
|
||||||
for k, v := range rc.GetEnv() {
|
evaluator := rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
|
||||||
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
var workflowEnv map[string]string
|
||||||
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
if rc.Run.Workflow.Env == nil {
|
||||||
|
if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, evaluator.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
|
||||||
|
return evaluated, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defaults := rc.Run.Job().Defaults.Run
|
if workflowEnv != nil {
|
||||||
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil {
|
evaluated = mergeMaps(workflowEnv, env)
|
||||||
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err)
|
evaluator = rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
|
||||||
}
|
}
|
||||||
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil {
|
var err error
|
||||||
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err)
|
for k, v := range env {
|
||||||
|
if evaluated[k], err = evaluator.Interpolate(ctx, v); err != nil {
|
||||||
|
return evaluated, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return evaluated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
env, err := evaluateJobEnv(ctx, rc)
|
||||||
|
rc.Env = env
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var defaults model.Defaults
|
||||||
|
if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc.jobRunDefaults = defaults.Run
|
||||||
|
return model.DecodeEvaluated("container env", rc.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;
|
||||||
@@ -466,7 +476,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,7 +532,7 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
|||||||
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
||||||
actPath := rc.JobContainer.GetActPath()
|
actPath := rc.JobContainer.GetActPath()
|
||||||
// Reuse a single client across all step uploads so connections can be pooled.
|
// Reuse a single client across all step uploads so connections can be pooled.
|
||||||
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout}
|
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout, Transport: rc.Config.instanceTransportFor(base)}
|
||||||
for i := range rc.Run.Job().Steps {
|
for i := range rc.Run.Job().Steps {
|
||||||
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
||||||
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
||||||
@@ -534,6 +544,15 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
// instanceTransportFor returns InstanceTransport only for URLs on the registered address, as steps can rewrite the summary URL through GITHUB_ENV.
|
||||||
|
func (c Config) instanceTransportFor(rawURL string) http.RoundTripper {
|
||||||
|
if !strings.HasPrefix(rawURL, c.GitHubInstance+"/") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.InstanceTransport
|
||||||
|
}
|
||||||
|
|
||||||
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
||||||
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
||||||
func extractJobIDFromRuntimeToken(token string) int64 {
|
func extractJobIDFromRuntimeToken(token string) int64 {
|
||||||
|
|||||||
@@ -479,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)
|
||||||
@@ -859,7 +858,7 @@ func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
|||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
func TestTryUploadJobSummaryUploadsEachStepIndependentlyWithInstanceTransport(t *testing.T) {
|
||||||
runtimeToken := fakeRuntimeToken(34)
|
runtimeToken := fakeRuntimeToken(34)
|
||||||
|
|
||||||
type upload struct {
|
type upload struct {
|
||||||
@@ -867,7 +866,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
|||||||
body string
|
body string
|
||||||
}
|
}
|
||||||
var got []upload
|
var got []upload
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
got = append(got, upload{r.URL.Path, string(body)})
|
got = append(got, upload{r.URL.Path, string(body)})
|
||||||
@@ -897,6 +896,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
|||||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
||||||
"GITEA_RUN_ID": "12",
|
"GITEA_RUN_ID": "12",
|
||||||
}, cm, 3)
|
}, cm, 3)
|
||||||
|
rc.Config.GitHubInstance, rc.Config.InstanceTransport = server.URL, server.Client().Transport
|
||||||
|
|
||||||
tryUploadJobSummary(ctx, rc)
|
tryUploadJobSummary(ctx, rc)
|
||||||
|
|
||||||
@@ -1082,6 +1082,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()))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+56
-114
@@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -20,68 +19,47 @@ 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))
|
||||||
|
|
||||||
// If the repository is private, we need a token to clone it
|
|
||||||
token := rc.Config.GetToken()
|
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||||
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())
|
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,23 +68,22 @@ 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 string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
||||||
}
|
}
|
||||||
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
return git.NewGitCloneExecutor(rc.Config.withInstanceAuth(git.NewGitCloneExecutorInput{
|
||||||
URL: interpolatedURL,
|
URL: interpolatedURL,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
Dir: targetDirectory,
|
Dir: targetDirectory,
|
||||||
Token: token,
|
|
||||||
OfflineMode: rc.Config.ActionOfflineMode,
|
OfflineMode: rc.Config.ActionOfflineMode,
|
||||||
Depth: rc.Config.ActionCloneDepth,
|
Depth: rc.Config.ActionCloneDepth,
|
||||||
})(ctx)
|
}, rc.Config.GetToken()))(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,64 +130,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
|
||||||
@@ -255,13 +202,18 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Ex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// getGitCloneToken returns GITEA_TOKEN when shouldCloneURLUseToken returns true,
|
// withInstanceAuth passes instance credentials only to clones from this Gitea instance, and runner.insecure only to its registered address.
|
||||||
// otherwise returns an empty string
|
func (c Config) withInstanceAuth(input git.NewGitCloneExecutorInput, token string) git.NewGitCloneExecutorInput {
|
||||||
func getGitCloneToken(conf *Config, cloneURL string) string {
|
u, err := url.Parse(input.URL)
|
||||||
if !shouldCloneURLUseToken(conf.GitHubInstance, conf.trustedActionInstance(), cloneURL) {
|
if err != nil || !onInstanceHost(u.Host, c.GitHubInstance, c.trustedActionInstance()) {
|
||||||
return ""
|
return input
|
||||||
}
|
}
|
||||||
return conf.GetToken()
|
input.ClientCertFile, input.ClientKeyFile = c.ClientCertFile, c.ClientKeyFile
|
||||||
|
input.InsecureSkipTLS = c.InsecureSkipTLS && onInstanceHost(u.Host, c.GitHubInstance)
|
||||||
|
if u.User == nil {
|
||||||
|
input.Token = token
|
||||||
|
}
|
||||||
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
@@ -275,26 +227,16 @@ func (c Config) trustedActionInstance() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// shouldCloneURLUseToken returns true when the following conditions are met:
|
// onInstanceHost reports whether host is that of one of the instance URLs, which may lack a scheme.
|
||||||
// 1. cloneURL's host matches this Gitea instance: either the registered instance
|
func onInstanceHost(host string, instanceURLs ...string) bool {
|
||||||
// (instanceURL) or, for DEFAULT_ACTIONS_URL=self on a different hostname, the
|
for _, candidate := range instanceURLs {
|
||||||
// self-hosted action instance (trustedActionInstance, "" when not trusted)
|
|
||||||
// 2. the cloneURL does not have basic auth embedded
|
|
||||||
func shouldCloneURLUseToken(instanceURL, trustedActionInstance, cloneURL string) bool {
|
|
||||||
u2, err := url.Parse(cloneURL)
|
|
||||||
if err != nil || u2.User != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, candidate := range []string{instanceURL, trustedActionInstance} {
|
|
||||||
if candidate == "" {
|
if candidate == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(candidate, "http://") &&
|
if !strings.HasPrefix(candidate, "http://") && !strings.HasPrefix(candidate, "https://") {
|
||||||
!strings.HasPrefix(candidate, "https://") {
|
|
||||||
candidate = "https://" + candidate
|
candidate = "https://" + candidate
|
||||||
}
|
}
|
||||||
if u1, err := url.Parse(candidate); err == nil && u1.Host == u2.Host {
|
if u, err := url.Parse(candidate); err == nil && u.Host == host {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -58,7 +64,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cacheDir := t.TempDir()
|
cacheDir := t.TempDir()
|
||||||
|
|
||||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(context.Background()))
|
||||||
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, tmpl("v1"), string(got))
|
require.Equal(t, tmpl("v1"), string(got))
|
||||||
@@ -68,7 +74,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|||||||
gitMust(t, workDir, "commit", "-am", "v2")
|
gitMust(t, workDir, "commit", "-am", "v2")
|
||||||
gitMust(t, workDir, "push", "origin", "master")
|
gitMust(t, workDir, "push", "origin", "master")
|
||||||
|
|
||||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(context.Background()))
|
||||||
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
||||||
@@ -106,107 +112,95 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) {
|
||||||
conf := &Config{
|
workdir := t.TempDir()
|
||||||
GitHubInstance: "gitea.example.net",
|
require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755))
|
||||||
Secrets: map[string]string{
|
require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644))
|
||||||
"GITEA_TOKEN": "token-value",
|
|
||||||
},
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
token := getGitCloneToken(conf, "https://gitea.example.net/actions/tools")
|
|
||||||
|
|
||||||
require.Equal(t, "token-value", token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGitCloneTokenSelfHostedActionsDifferentHost(t *testing.T) {
|
func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T) {
|
||||||
// The runner registered with one hostname while DEFAULT_ACTIONS_URL=self resolves
|
var gotCert bool
|
||||||
// actions against AppURL on a different hostname for the same instance.
|
var gotToken string
|
||||||
conf := &Config{
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
GitHubInstance: "gitea.local",
|
gotCert = len(r.TLS.PeerCertificates) > 0
|
||||||
DefaultActionInstance: "https://gitea.my-nas.lan",
|
_, gotToken, _ = r.BasicAuth()
|
||||||
DefaultActionInstanceIsSelfHosted: true,
|
w.WriteHeader(http.StatusNotFound)
|
||||||
Secrets: map[string]string{
|
}))
|
||||||
"GITEA_TOKEN": "token-value",
|
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
|
||||||
|
server.StartTLS()
|
||||||
|
defer server.Close()
|
||||||
|
pair := server.TLS.Certificates[0]
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(pair.PrivateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
dir := t.TempDir()
|
||||||
|
certFile, keyFile := filepath.Join(dir, "client.crt"), filepath.Join(dir, "client.key")
|
||||||
|
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate[0]}), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{
|
||||||
|
GitHubInstance: server.URL,
|
||||||
|
Secrets: map[string]string{"GITEA_TOKEN": "token"},
|
||||||
|
InsecureSkipTLS: true,
|
||||||
|
ClientCertFile: certFile,
|
||||||
|
ClientKeyFile: keyFile,
|
||||||
},
|
},
|
||||||
|
Run: &model.Run{JobID: "j1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"j1": {}}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
token := getGitCloneToken(conf, "https://gitea.my-nas.lan/owner/action")
|
require.Error(t, cloneRemoteReusableWorkflow(rc, server.URL+"/owner/repo", "main", t.TempDir())(t.Context()))
|
||||||
|
require.True(t, gotCert)
|
||||||
require.Equal(t, "token-value", token)
|
require.Equal(t, "token", gotToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldCloneURLUseToken(t *testing.T) {
|
func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
|
||||||
tests := []struct {
|
for _, tt := range []struct {
|
||||||
name string
|
name, instanceURL, trustedActionInstance, cloneURL string
|
||||||
instanceURL string
|
token, cert, insecure bool
|
||||||
trustedActionInstance string
|
|
||||||
cloneURL string
|
|
||||||
want bool
|
|
||||||
}{
|
}{
|
||||||
{
|
{"same host with schemaless instance", "gitea.example.net", "", "https://gitea.example.net/actions/tools", true, true, true},
|
||||||
name: "same host with schemaless instance",
|
{"same host with schemaless instance and port", "gitea.example.net:3000", "", "https://gitea.example.net:3000/actions/tools", true, true, true},
|
||||||
instanceURL: "gitea.example.net",
|
{"different host", "gitea.example.net", "", "https://github.com/actions/tools", false, false, false},
|
||||||
cloneURL: "https://gitea.example.net/actions/tools",
|
{"embedded basic auth keeps its own credentials", "gitea.example.net", "", "https://user:pass@gitea.example.net/actions/tools", false, true, true},
|
||||||
want: true,
|
{"invalid clone URL", "gitea.example.net", "", "://gitea.example.net/actions/tools", false, false, false},
|
||||||
},
|
{"self-hosted action instance on different host skips no verification", "gitea.local", "https://gitea.my-nas.lan", "https://gitea.my-nas.lan/owner/action", true, true, false},
|
||||||
{
|
{"self-hosted action instance with embedded basic auth", "gitea.local", "https://gitea.my-nas.lan", "https://user:[email protected]/owner/action", false, true, false},
|
||||||
name: "same host with schemaless instance and port",
|
{"github mode does not trust mirror host", "gitea.local", "", "https://mirror.example.com/owner/action", false, false, false},
|
||||||
instanceURL: "gitea.example.net:3000",
|
} {
|
||||||
cloneURL: "https://gitea.example.net:3000/actions/tools",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "different host",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "https://github.com/actions/tools",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "embedded basic auth",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "https://user:[email protected]/actions/tools",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid clone URL",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "://gitea.example.net/actions/tools",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// self-hosted DEFAULT_ACTIONS_URL on a different hostname than the
|
|
||||||
// registered instance: the token must still be attached.
|
|
||||||
name: "self-hosted action instance on different host",
|
|
||||||
instanceURL: "gitea.local",
|
|
||||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
|
||||||
cloneURL: "https://gitea.my-nas.lan/owner/action",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// embedded basic auth must still be rejected even when the host matches
|
|
||||||
// the trusted action instance.
|
|
||||||
name: "self-hosted action instance with embedded basic auth",
|
|
||||||
instanceURL: "gitea.local",
|
|
||||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
|
||||||
cloneURL: "https://user:[email protected]/owner/action",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// github.com / mirror hosts are never trusted: trustedActionInstance is
|
|
||||||
// empty in github mode, so an off-instance clone URL gets no token.
|
|
||||||
name: "github mode does not trust mirror host",
|
|
||||||
instanceURL: "gitea.local",
|
|
||||||
cloneURL: "https://mirror.example.com/owner/action",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.trustedActionInstance, tt.cloneURL))
|
conf := Config{
|
||||||
|
GitHubInstance: tt.instanceURL,
|
||||||
|
DefaultActionInstance: tt.trustedActionInstance,
|
||||||
|
DefaultActionInstanceIsSelfHosted: tt.trustedActionInstance != "",
|
||||||
|
InsecureSkipTLS: true,
|
||||||
|
ClientCertFile: "/cert",
|
||||||
|
ClientKeyFile: "/key",
|
||||||
|
}
|
||||||
|
want := git.NewGitCloneExecutorInput{URL: tt.cloneURL, InsecureSkipTLS: tt.insecure}
|
||||||
|
if tt.token {
|
||||||
|
want.Token = "token"
|
||||||
|
}
|
||||||
|
if tt.cert {
|
||||||
|
want.ClientCertFile, want.ClientKeyFile = conf.ClientCertFile, conf.ClientKeyFile
|
||||||
|
}
|
||||||
|
require.Equal(t, want, conf.withInstanceAuth(git.NewGitCloneExecutorInput{URL: tt.cloneURL}, "token"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{}
|
||||||
|
conf := Config{GitHubInstance: "https://gitea.local", InstanceTransport: transport}
|
||||||
|
require.Same(t, transport, conf.instanceTransportFor("https://gitea.local/api/actions_pipeline/"))
|
||||||
|
require.Nil(t, conf.instanceTransportFor("https://gitea.local.example/api/actions_pipeline/"))
|
||||||
|
require.Nil(t, conf.instanceTransportFor("https://other.example/x#/api/actions_pipeline/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitMust(t *testing.T, dir string, args ...string) {
|
func gitMust(t *testing.T, dir string, args ...string) {
|
||||||
|
|||||||
+381
-313
File diff suppressed because it is too large.
Load diff
+453
-159
@@ -7,15 +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"
|
||||||
"net/http/httptest"
|
"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"
|
||||||
@@ -24,7 +30,9 @@ 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"
|
||||||
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
require "github.com/stretchr/testify/require"
|
require "github.com/stretchr/testify/require"
|
||||||
@@ -178,38 +186,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.
|
||||||
@@ -240,8 +216,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))
|
||||||
@@ -263,7 +251,6 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
|
|||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Env: map[string]string{},
|
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: "job",
|
JobID: "job",
|
||||||
Workflow: workflow,
|
Workflow: workflow,
|
||||||
@@ -282,6 +269,8 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
|
|||||||
_, _ = io.WriteString(w, "[]")
|
_, _ = io.WriteString(w, "[]")
|
||||||
case strings.HasSuffix(r.URL.Path, "/volumes"):
|
case strings.HasSuffix(r.URL.Path, "/volumes"):
|
||||||
_, _ = io.WriteString(w, `{"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"):
|
case strings.HasSuffix(r.URL.Path, "/info"):
|
||||||
_, _ = io.WriteString(w, `{"Architecture":"amd64","OSType":"linux"}`)
|
_, _ = io.WriteString(w, `{"Architecture":"amd64","OSType":"linux"}`)
|
||||||
default:
|
default:
|
||||||
@@ -349,6 +338,52 @@ 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 TestStartJobContainerBuildsEvaluatedJobAndServiceInputs(t *testing.T) {
|
||||||
|
inputs := startJobContainerInputs(t, `
|
||||||
|
jobs:
|
||||||
|
job:
|
||||||
|
container: ${{ fromJSON('{"image":"docker://node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }}
|
||||||
|
services: ${{ fromJSON('{"redis":{"image":"docker://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, "sleep", job.Entrypoint[0])
|
||||||
|
require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions)
|
||||||
|
require.Equal(t, "${{ vars.TITLE }}", job.Password)
|
||||||
|
|
||||||
|
inputs = startJobContainerInputs(t, `
|
||||||
|
env:
|
||||||
|
REG_USER: ${{ vars.REG_USER }}
|
||||||
|
TEXT: ${{ vars.TEXT }}
|
||||||
|
JOB_CONTAINER_NAME: from-workflow
|
||||||
|
jobs:
|
||||||
|
job:
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
credentials: {username: "${{ env.REG_USER }}", password: pass}
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
credentials: {username: "${{ env.REG_USER }}", password: pass}
|
||||||
|
env: {TEXT: "${{ env.TEXT }}"}
|
||||||
|
options: --volumes-from ${{ env.JOB_CONTAINER_NAME }}
|
||||||
|
`, &Config{Vars: map[string]string{"REG_USER": "user", "TEXT": "${{ 'literal' }}"}})
|
||||||
|
redis, job = inputs[0], inputs[1]
|
||||||
|
require.Equal(t, "user", job.Username)
|
||||||
|
require.Equal(t, "user", redis.Username)
|
||||||
|
require.Equal(t, []string{"TEXT=${{ 'literal' }}"}, redis.Env)
|
||||||
|
require.Equal(t, "--volumes-from "+job.Name, redis.WorkflowOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -401,17 +436,14 @@ jobs:
|
|||||||
steps: []
|
steps: []
|
||||||
`, &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}})
|
`, &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}})
|
||||||
|
|
||||||
env := map[string][]string{}
|
db, redis := inputs[0], inputs[1]
|
||||||
for _, in := range inputs {
|
|
||||||
env[in.Image] = in.Env
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Contains(t, env["redis:latest"], "http_proxy=http://proxy:3128")
|
require.Contains(t, redis.Env, "http_proxy=http://proxy:3128")
|
||||||
require.Contains(t, env["redis:latest"], "no_proxy=internal.example")
|
require.Contains(t, redis.Env, "no_proxy=internal.example")
|
||||||
// the service's own env wins over what the runner injected, without dropping the rest
|
// the service's own env wins over what the runner injected, without dropping the rest
|
||||||
require.Contains(t, env["postgres:latest"], "no_proxy=db-only.example")
|
require.Contains(t, db.Env, "no_proxy=db-only.example")
|
||||||
require.NotContains(t, env["postgres:latest"], "no_proxy=internal.example")
|
require.NotContains(t, db.Env, "no_proxy=internal.example")
|
||||||
require.Contains(t, env["postgres:latest"], "http_proxy=http://proxy:3128")
|
require.Contains(t, db.Env, "http_proxy=http://proxy:3128")
|
||||||
}
|
}
|
||||||
|
|
||||||
// act builds Dockerfile actions through the API, which does not pre-populate the proxy
|
// act builds Dockerfile actions through the API, which does not pre-populate the proxy
|
||||||
@@ -472,8 +504,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(t.Context())
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Name binds/mounts are either/or
|
// Name binds/mounts are either/or
|
||||||
if config.BindWorkdir {
|
if config.BindWorkdir {
|
||||||
@@ -509,8 +540,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)
|
||||||
|
|
||||||
@@ -522,7 +554,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Config: &Config{
|
Config: &Config{
|
||||||
BindWorkdir: false,
|
BindWorkdir: false,
|
||||||
|
ValidVolumes: []string{"**"},
|
||||||
Secrets: map[string]string{
|
Secrets: map[string]string{
|
||||||
"MAME": "/host/mame/roms",
|
"MAME": "/host/mame/roms",
|
||||||
},
|
},
|
||||||
@@ -531,9 +564,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(t.Context())
|
||||||
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 }}")
|
||||||
@@ -541,12 +574,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{
|
||||||
@@ -557,13 +584,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
Config: &Config{
|
Config: &Config{
|
||||||
BindWorkdir: false,
|
BindWorkdir: false,
|
||||||
SharedToolCache: true, // so OverridesToolCache has a mount to displace
|
SharedToolCache: true, // so OverridesToolCache has a mount to displace
|
||||||
|
ValidVolumes: []string{"**"},
|
||||||
},
|
},
|
||||||
|
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(t.Context())
|
||||||
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)
|
||||||
@@ -598,15 +625,35 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, gotmount, err := rc.GetBindsAndMounts()
|
_, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||||
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()
|
rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
|
||||||
require.NoError(t, err)
|
_, gotmount = rc.GetBindsAndMounts(t.Context())
|
||||||
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{}, ValidVolumes: []string{"claimed"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
gotbind, _ := rc.GetBindsAndMounts(t.Context())
|
||||||
|
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(t.Context())
|
||||||
|
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
|
||||||
|
|
||||||
|
rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
|
||||||
|
_, gotmount = rc.GetBindsAndMounts(t.Context())
|
||||||
|
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextValidVolumes(t *testing.T) {
|
func TestRunContextValidVolumes(t *testing.T) {
|
||||||
@@ -635,6 +682,17 @@ 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"))
|
||||||
|
|
||||||
|
hostEnv := &container.HostEnvironment{Path: t.TempDir(), ActPath: t.TempDir()}
|
||||||
|
rc.JobContainer = hostEnv
|
||||||
|
rc.Config.BindWorkdir = false
|
||||||
|
rc.Config.SharedToolCache = true
|
||||||
|
binds, mounts := rc.GetBindsAndMounts(t.Context())
|
||||||
|
assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()})
|
||||||
|
assert.Contains(t, mounts, sharedToolCacheVolume)
|
||||||
|
assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||||
@@ -647,60 +705,197 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
|||||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := rc.cleanupJobResources("external-network", false)(common.WithDryrun(t.Context(), true))
|
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")
|
|
||||||
|
|
||||||
removeError, closeError := errors.New("remove service"), errors.New("close service")
|
|
||||||
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 removeError }).Once()
|
|
||||||
service.On("Close").Return(func(context.Context) error { return closeError }).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")
|
||||||
err := rc.cleanupJobResources("job-network", true)(ctx)
|
switch {
|
||||||
require.ErrorContains(t, err, "removal failed")
|
case path == "/volumes/create":
|
||||||
require.ErrorIs(t, err, removeError)
|
options := volume.Volume{CreatedAt: now.Format(time.RFC3339)}
|
||||||
require.ErrorIs(t, err, closeError)
|
assert.NoError(t, json.UnmarshalRead(request.Body, &options))
|
||||||
require.ErrorIs(t, err, context.Canceled)
|
volumes[options.Name] = options
|
||||||
jobContainer.AssertExpectations(t)
|
assert.NoError(t, json.MarshalWrite(writer, volumes[options.Name]))
|
||||||
service.AssertExpectations(t)
|
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 TestRemoveDockerActionImagesByTag(t *testing.T) {
|
||||||
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
var removed []string
|
||||||
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
|
if request.Method == http.MethodDelete {
|
||||||
|
removed = append(removed, strings.TrimPrefix(request.URL.Path, "/v1.47/images/"))
|
||||||
|
_, _ = io.WriteString(writer, "[]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(writer, `{"Id":"sha256:shared"}`)
|
||||||
|
})
|
||||||
|
(&RunContext{dockerActionImages: map[*model.Step]string{{}: "act-action-dockeraction:latest-job"}}).removeDockerActionImages(t.Context())
|
||||||
|
assert.Equal(t, []string{"act-action-dockeraction:latest-job"}, removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||||
|
t.Setenv("TMPDIR", "/tmp")
|
||||||
|
proxyDir := t.TempDir()
|
||||||
|
for _, name := range []string{"synchronous", "proxy", "closed proxy", "kept 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")
|
||||||
|
removesLabelled := (proxy || preclean) && name != "kept proxy"
|
||||||
|
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", NoSweep: name == "kept proxy"},
|
||||||
|
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, removesLabelled, strings.Contains(err.Error(), "labelled GET /containers/json"))
|
||||||
|
if removesLabelled {
|
||||||
|
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) {
|
||||||
@@ -759,6 +954,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) {
|
||||||
@@ -805,7 +1006,6 @@ func TestGetGithubContextRef(t *testing.T) {
|
|||||||
func createIfTestRunContext(jobs map[string]*model.Job) *RunContext {
|
func createIfTestRunContext(jobs map[string]*model.Job) *RunContext {
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }},
|
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }},
|
||||||
Env: map[string]string{},
|
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: "job1",
|
JobID: "job1",
|
||||||
Workflow: &model.Workflow{
|
Workflow: &model.Workflow{
|
||||||
@@ -882,9 +1082,12 @@ func TestRunContextIsEnabled(t *testing.T) {
|
|||||||
// success()
|
// success()
|
||||||
rc := createIfTestRunContext(map[string]*model.Job{
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest
|
"job1": createJob(t, `runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
BROKEN: ${{ fromJSON('invalid') }}
|
||||||
if: success()`, ""),
|
if: success()`, ""),
|
||||||
})
|
})
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(context.Background()))
|
||||||
|
require.Error(t, evaluateJobEnvAndDefaults(context.Background(), rc))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||||
@@ -988,6 +1191,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) {
|
||||||
@@ -1089,65 +1299,109 @@ 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 context.CancelFunc
|
cancel()
|
||||||
}
|
}
|
||||||
newRC := func(c *capture) *RunContext {
|
calls := 0
|
||||||
return &RunContext{
|
(&RunContext{cleanUpJobContainer: func(ctx context.Context) error {
|
||||||
JobName: "job",
|
calls++
|
||||||
cleanUpJobContainer: func(ctx context.Context) error {
|
cancel()
|
||||||
c.calls++
|
|
||||||
if c.cancel != nil {
|
|
||||||
c.cancel()
|
|
||||||
}
|
|
||||||
deadline, ok := ctx.Deadline()
|
deadline, ok := ctx.Deadline()
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, time.Second)
|
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, time.Second)
|
||||||
c.err = ctx.Err()
|
require.NoError(t, ctx.Err())
|
||||||
c.sentinel = ctx.Value(sentinel)
|
assert.Equal(t, "v", ctx.Value(sentinel))
|
||||||
return nil
|
return nil
|
||||||
},
|
}}).cleanupFailedStart(ctx)
|
||||||
}
|
assert.Equal(t, 1, calls)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("detaches teardown from cancellation during cleanup", func(t *testing.T) {
|
for _, testcase := range []struct {
|
||||||
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
|
name, health string
|
||||||
defer cancel()
|
pullError, startError error
|
||||||
c := capture{cancel: cancel}
|
}{
|
||||||
|
{name: "healthy", health: container.HealthHealthy},
|
||||||
|
{name: "unhealthy", health: container.HealthUnhealthy},
|
||||||
|
{name: "pull failure", pullError: errors.New("pull failed")},
|
||||||
|
{name: "start failure", startError: errors.New("start 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", map[string]error{"service": testcase.startError}[name]))
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
stdouts := map[string]io.Writer{}
|
||||||
|
service.On("DumpLogs", mock.Anything).Return(nil).Run(func(mock.Arguments) {
|
||||||
|
operations = append(operations, "service.DumpLogs")
|
||||||
|
fmt.Fprintln(stdouts["postgres:latest"], "::add-mask::leak")
|
||||||
|
})
|
||||||
|
origNewContainer := newContainer
|
||||||
|
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
|
stdouts[input.Image] = input.Stdout
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
logger, hook := logrustest.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(common.WithDryrun(t.Context(), true), logger)
|
||||||
|
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"
|
||||||
|
switch {
|
||||||
|
case testcase.pullError != nil:
|
||||||
|
require.ErrorIs(t, err, testcase.pullError)
|
||||||
|
case testcase.startError != nil:
|
||||||
|
require.ErrorIs(t, err, testcase.startError)
|
||||||
|
want += " service.Create service.Start service.DumpLogs"
|
||||||
|
default:
|
||||||
|
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect service.DumpLogs"
|
||||||
|
if testcase.health == container.HealthUnhealthy {
|
||||||
|
require.ErrorContains(t, err, "the service 'postgres' is unhealthy")
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations)
|
||||||
|
assert.Empty(t, rc.Masks)
|
||||||
|
assert.Equal(t, testcase.pullError == nil && testcase.health != container.HealthUnhealthy, slices.ContainsFunc(hook.AllEntries(), func(entry *log.Entry) bool {
|
||||||
|
return strings.HasPrefix(entry.Message, "::group::Print service container logs")
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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.Equal(t, "v", 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},
|
||||||
@@ -1165,16 +1419,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) {
|
||||||
@@ -1331,7 +1595,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/"},
|
||||||
@@ -1342,7 +1606,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{}
|
||||||
@@ -1386,12 +1650,42 @@ func TestImageOSFromImage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolvePlatformImageAppliesImagelessContainerToRunsOnImage(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, image string
|
||||||
|
want model.ContainerSpec
|
||||||
|
}{
|
||||||
|
{"without image key", "", model.ContainerSpec{Volumes: []string{"/host/data:/data"}, Env: map[string]string{"KEY": "value"}}},
|
||||||
|
{"with empty image", "image: '', ", model.ContainerSpec{}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
|
"job1": createJob(t, "runs-on: toolkit\ncontainer: {"+tc.image+"volumes: [/host/data:/data], env: {KEY: value}}", ""),
|
||||||
|
})
|
||||||
|
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||||
|
require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc))
|
||||||
|
|
||||||
|
assert.Equal(t, "ubuntu-latest", rc.platformImage)
|
||||||
|
assert.Equal(t, tc.want, rc.containerSpec)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext {
|
func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext {
|
||||||
return createIfTestRunContext(map[string]*model.Job{
|
return createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, "runs-on: "+runsOn, ""),
|
"job1": createJob(t, "runs-on: "+runsOn, ""),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunContextContainerImageCannotSelectBackend(t *testing.T) {
|
||||||
|
rc := createIfTestRunContext(map[string]*model.Job{"job1": createJob(t, "container: -self-hosted", "")})
|
||||||
|
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||||
|
assert.False(t, rc.IsHostEnv())
|
||||||
|
|
||||||
|
rc = createIfTestRunContext(map[string]*model.Job{"job1": createJob(t, "runs-on: ${{ fromJSON('x') }}\ncontainer: node", "")})
|
||||||
|
require.ErrorContains(t, rc.resolvePlatformImage(t.Context()), "runs-on")
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunContextImageOS(t *testing.T) {
|
func TestRunContextImageOS(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
|||||||
+71
-32
@@ -7,18 +7,21 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"sync"
|
"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/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,10 +59,12 @@ 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
|
||||||
|
|
||||||
PresetGitHubContext *model.GithubContext // overrides actor, ref, repository, token and related context fields
|
PresetGitHubContext *model.GithubContext // overrides actor, ref, repository, token and related context fields
|
||||||
|
WorkflowCallInputs map[string]any // the `inputs` Gitea resolved for a called workflow's job, nil for other jobs
|
||||||
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
||||||
ContainerNamePrefix string // the prefix of container name
|
ContainerNamePrefix string // the prefix of container name
|
||||||
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
||||||
@@ -72,13 +77,17 @@ type Config struct {
|
|||||||
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
|
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
|
||||||
DefaultActionInstanceIsSelfHosted bool
|
DefaultActionInstanceIsSelfHosted bool
|
||||||
PlatformPicker func(labels []string) string
|
PlatformPicker func(labels []string) string
|
||||||
|
KubernetesPicker func(labels []string) container.KubernetesOptions
|
||||||
|
InstanceTransport http.RoundTripper
|
||||||
JobLoggerLevel *log.Level // the level of job logger
|
JobLoggerLevel *log.Level // the level of job logger
|
||||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||||
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
||||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||||
|
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
|
||||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||||
|
NoSweep bool // keep the containers, networks and volumes a job creates through its docker proxy
|
||||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||||
@@ -155,8 +164,29 @@ 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 {
|
||||||
|
for _, stage := range plan.Stages {
|
||||||
|
for _, run := range stage.Runs {
|
||||||
|
job := run.Job()
|
||||||
|
if runsOn := job.RunsOn(); job.Uses == "" && len(job.Steps) > 0 && (len(runsOn) == 0 || slices.Contains(runsOn, "")) { // Gitea's needs placeholders carry no steps
|
||||||
|
return common.NewErrorExecutor(fmt.Errorf("job %q requires a non-empty 'runs-on'", run.JobID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
maxJobNameLen := 0
|
maxJobNameLen := 0
|
||||||
|
|
||||||
stagePipeline := make([]common.Executor, 0)
|
stagePipeline := make([]common.Executor, 0)
|
||||||
@@ -191,21 +221,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 +253,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 +339,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 +357,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 +368,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"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
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/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -29,6 +31,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 {
|
||||||
@@ -149,6 +152,21 @@ func TestGraphWithSomeMissing(t *testing.T) {
|
|||||||
log.SetOutput(out)
|
log.SetOutput(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewPlanExecutorRequiresRunsOnBeforeRunningAnyJob(t *testing.T) {
|
||||||
|
for _, runsOn := range []string{"", "if: false", "runs-on: [ubuntu-latest, '']"} {
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n first:\n runs-on: ubuntu-latest\n steps: [run: echo]\n test:\n needs: first\n " + runsOn + "\n steps: [run: echo]\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
plan, err := model.CombineWorkflowPlanner(workflow).PlanJob("test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ErrorContains(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(plan)(t.Context()), `job "test" requires a non-empty 'runs-on'`, runsOn)
|
||||||
|
assert.Empty(t, workflow.Jobs["first"].Result, runsOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow := &model.Workflow{Jobs: map[string]*model.Job{"build": {Result: "success"}}}
|
||||||
|
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "build"}}}}})(t.Context()))
|
||||||
|
assert.Equal(t, "success", workflow.Jobs["build"].Result)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGraphEvent(t *testing.T) {
|
func TestGraphEvent(t *testing.T) {
|
||||||
planner, err := model.NewWorkflowPlanner("testdata/basic", true)
|
planner, err := model.NewWorkflowPlanner("testdata/basic", true)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
@@ -216,10 +234,9 @@ 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,
|
|
||||||
Env: cfg.Env,
|
Env: cfg.Env,
|
||||||
Secrets: cfg.Secrets,
|
Secrets: cfg.Secrets,
|
||||||
GitHubInstance: "github.com",
|
GitHubInstance: "github.com",
|
||||||
@@ -346,7 +363,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 +449,9 @@ 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, "uses-docker-url", "push", "", platforms, secrets},
|
||||||
|
{workdir, "local-action-dockerfile", "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},
|
||||||
@@ -448,6 +468,8 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
requireHostTools(t, "pwsh")
|
requireHostTools(t, "pwsh")
|
||||||
case "nix-prepend-path":
|
case "nix-prepend-path":
|
||||||
requireHostTools(t, "nix")
|
requireHostTools(t, "nix")
|
||||||
|
case "uses-docker-url", "local-action-dockerfile":
|
||||||
|
requireDockerSharingFiles(t)
|
||||||
}
|
}
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
hostPlanSlots <- struct{}{}
|
hostPlanSlots <- struct{}{}
|
||||||
|
|||||||
+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,73 @@
|
|||||||
|
// 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
|
||||||
|
rc.ApplyExtraPath(ctx, &sab.env)
|
||||||
|
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,
|
||||||
|
ClientCertFile: rc.Config.ClientCertFile,
|
||||||
|
ClientKeyFile: rc.Config.ClientKeyFile,
|
||||||
|
Env: sab.env,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -61,11 +61,14 @@ func (sal *stepActionLocal) main() common.Executor {
|
|||||||
treader := tar.NewReader(tars)
|
treader := tar.NewReader(tars)
|
||||||
header, err := treader.Next()
|
header, err := treader.Next()
|
||||||
if errors.Is(err, io.EOF) {
|
if errors.Is(err, io.EOF) {
|
||||||
|
tars.Close()
|
||||||
return nil, nil, os.ErrNotExist
|
return nil, nil, os.ErrNotExist
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
|
tars.Close()
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
|
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||||
|
tars.Close()
|
||||||
spath, err = symlinkJoin(spath, header.Linkname, containerActionPath)
|
spath, err = symlinkJoin(spath, header.Linkname, containerActionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
gogit "github.com/go-git/go-git/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type stepActionRemote struct {
|
type stepActionRemote struct {
|
||||||
@@ -58,13 +56,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 {
|
||||||
@@ -74,36 +67,22 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
actionDir := sar.actionDir()
|
actionDir := sar.actionDir()
|
||||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
// github.Token, unlike Config.GetToken, survives the composite config copy, which nils Config.Secrets.
|
||||||
// empty token and clone the action anonymously (401 against the authenticated
|
gitClone := stepActionRemoteNewCloneExecutor(sar.RunContext.Config.withInstanceAuth(git.NewGitCloneExecutorInput{
|
||||||
// instance). github.Token survives the composite config copy and matches the
|
URL: sar.remoteAction.CloneURL(defaultActionURL),
|
||||||
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
|
|
||||||
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
|
|
||||||
token := ""
|
|
||||||
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
|
|
||||||
token = github.Token
|
|
||||||
}
|
|
||||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
|
||||||
URL: cloneURL,
|
|
||||||
Ref: sar.remoteAction.Ref,
|
Ref: sar.remoteAction.Ref,
|
||||||
Dir: actionDir,
|
Dir: actionDir,
|
||||||
Token: token,
|
|
||||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||||
Depth: sar.RunContext.Config.ActionCloneDepth,
|
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||||
// printPrepareActions reports the download with its resolved commit.
|
// printPrepareActions reports the download with its resolved commit.
|
||||||
Quiet: true,
|
Quiet: true,
|
||||||
|
}, github.Token))
|
||||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
|
||||||
})
|
|
||||||
var ntErr common.Executor
|
|
||||||
if err := gitClone(ctx); err != nil {
|
if err := gitClone(ctx); err != nil {
|
||||||
var refErr *git.Error
|
var refErr *git.Error
|
||||||
switch {
|
switch {
|
||||||
case errors.As(err, &refErr) && errors.Is(err, git.ErrShortRef):
|
case errors.As(err, &refErr) && errors.Is(err, git.ErrShortRef):
|
||||||
return fmt.Errorf("unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
return fmt.Errorf("unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
||||||
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
||||||
case errors.Is(err, gogit.ErrForceNeeded): // TODO: figure out if it will be easy to shadow/alias go-git err's
|
|
||||||
ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err)
|
|
||||||
default:
|
default:
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -121,15 +100,10 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
return f, f, err
|
return f, f, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
defer git.AcquireCloneLock(actionDir)()
|
||||||
ntErr,
|
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile)
|
||||||
func(ctx context.Context) error {
|
sar.action = actionModel
|
||||||
defer git.AcquireCloneLock(actionDir)()
|
return err
|
||||||
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile)
|
|
||||||
sar.action = actionModel
|
|
||||||
return err
|
|
||||||
},
|
|
||||||
)(ctx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,11 +138,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,22 +239,6 @@ func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
|||||||
return sar.compositeSteps
|
return sar.compositeSteps
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
|
||||||
// cloneSkipTLS returns true if the runner can clone an action from the Gitea instance
|
|
||||||
func (sar *stepActionRemote) cloneSkipTLS() bool {
|
|
||||||
if !sar.RunContext.Config.InsecureSkipTLS {
|
|
||||||
// Return false if the Gitea instance is not an insecure instance
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if sar.remoteAction.URL == "" {
|
|
||||||
// Empty URL means the default action instance should be used
|
|
||||||
// Return true if the URL of the Gitea instance is the same as the URL of the default action instance
|
|
||||||
return sar.RunContext.Config.DefaultActionURL() == sar.RunContext.Config.GitHubInstance
|
|
||||||
}
|
|
||||||
// Return true if the URL of the remote action is the same as the URL of the Gitea instance
|
|
||||||
return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
type remoteAction struct {
|
type remoteAction struct {
|
||||||
URL string
|
URL string
|
||||||
Org string
|
Org string
|
||||||
@@ -324,61 +278,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"
|
||||||
@@ -57,30 +56,21 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
|
|||||||
step := sd.Step
|
step := sd.Step
|
||||||
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
image := strings.TrimPrefix(step.Uses, "docker://")
|
if rc.kubernetes {
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
return errKubernetesDocker
|
||||||
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)
|
image := strings.TrimPrefix(step.Uses, "docker://")
|
||||||
|
cmd, err := shellquote.Split(step.With["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),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -145,28 +146,41 @@ 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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
func TestStepDockerNewStepContainerNetworkModeAndPath(t *testing.T) {
|
||||||
|
inheritedPath := (&container.LinuxContainerEnvironmentExtensions{}).DefaultPathVariable()
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
platform string
|
platform string
|
||||||
|
path string
|
||||||
expectDefault bool
|
expectDefault bool
|
||||||
|
keepsPath bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "docker mode attaches to job container network",
|
name: "docker mode attaches to job container network",
|
||||||
platform: "node:14",
|
platform: "node:14",
|
||||||
|
path: inheritedPath,
|
||||||
expectDefault: false,
|
expectDefault: false,
|
||||||
|
keepsPath: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "host mode uses default network",
|
name: "host mode uses default network and the image's PATH",
|
||||||
platform: "-self-hosted",
|
platform: "-self-hosted",
|
||||||
|
path: inheritedPath,
|
||||||
expectDefault: true,
|
expectDefault: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "host mode keeps a PATH that is not the runner's",
|
||||||
|
platform: "-self-hosted",
|
||||||
|
path: "/custom/bin",
|
||||||
|
expectDefault: true,
|
||||||
|
keepsPath: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -208,13 +222,15 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
|||||||
ID: "1",
|
ID: "1",
|
||||||
Uses: "docker://alpine:3.20",
|
Uses: "docker://alpine:3.20",
|
||||||
},
|
},
|
||||||
|
env: map[string]string{"PATH": tc.path},
|
||||||
}
|
}
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
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, "")
|
||||||
|
assert.Equal(t, tc.keepsPath, slices.Contains(captured.Env, "PATH="+tc.path))
|
||||||
|
|
||||||
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
|
||||||
+48
@@ -6,3 +6,51 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: ./action
|
- 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"
|
||||||
+5
-2
@@ -5,6 +5,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- run: printf 'workspace=from-host-step\n' > workspace-marker
|
||||||
- uses: docker://node:24-bookworm-slim
|
- uses: docker://node:24-bookworm-slim
|
||||||
with:
|
with:
|
||||||
somekey: somevalue
|
somekey: somevalue
|
||||||
@@ -16,8 +17,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
entrypoint: /bin/sh
|
entrypoint: /bin/sh
|
||||||
args: -c -- uname -a
|
args: -c -- uname -a
|
||||||
- uses: docker://node:24-bookworm-slim
|
- id: workspace
|
||||||
|
uses: docker://node:24-bookworm-slim
|
||||||
with:
|
with:
|
||||||
entrypoint: /bin/sh
|
entrypoint: /bin/sh
|
||||||
args: -c 'uname -a'
|
args: -c 'uname -a && cat "$GITHUB_WORKSPACE/workspace-marker" >> "$GITHUB_OUTPUT"'
|
||||||
|
- run: test '${{ steps.workspace.outputs.workspace }}' = 'from-host-step'
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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. It runs `git` where the job runs, see the [prerequisites](../README.md#prerequisites).
|
||||||
|
|
||||||
|
| Input | Default |
|
||||||
|
| --- | --- |
|
||||||
|
| `repository` | `github.repository` |
|
||||||
|
| `ref` | the triggering ref and commit, or the default branch of another repository |
|
||||||
|
| `token` | `github.token` |
|
||||||
|
| `ssh-key` | empty, a private key fetches over SSH instead |
|
||||||
|
| `ssh-known-hosts` | empty, host keys trusted besides `~/.ssh/known_hosts` |
|
||||||
|
| `ssh-strict` | `true`, rejects unknown host keys |
|
||||||
|
| `ssh-user` | the user of the repository's SSH URL, else `git` |
|
||||||
|
| `persist-credentials` | `true`, later steps of the job can fetch and push with `token` or `ssh-key` |
|
||||||
|
| `path` | workspace root |
|
||||||
|
| `clean` | `true`, removes untracked files, also in submodules |
|
||||||
|
| `filter` | empty, a partial clone filter such as `blob:none` |
|
||||||
|
| `sparse-checkout` | empty, the paths to check out, one per line |
|
||||||
|
| `sparse-checkout-cone-mode` | `true`, `false` reads `sparse-checkout` as patterns |
|
||||||
|
| `fetch-depth` | `1`, `0` for the full history of every branch and tag |
|
||||||
|
| `fetch-tags` | `false`, `true` fetches every tag |
|
||||||
|
| `show-progress` | `true`, shows the fetch progress |
|
||||||
|
| `lfs` | `false`, `true` downloads Git LFS files |
|
||||||
|
| `submodules` | `false`, `true` checks out submodules, `recursive` also theirs |
|
||||||
|
| `set-safe-directory` | `true`, lets checkout's `git` work in a directory another user owns |
|
||||||
|
|
||||||
|
A branch is checked out as a local branch tracking `origin`, a tag or commit as a detached `HEAD`. A triggering tag that moved since the event fails the step. The step outputs `ref` and `commit` like `actions/checkout`.
|
||||||
|
|
||||||
|
Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Without `ssh-key`, submodule URLs of the form `git@<server host>:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22.
|
||||||
|
|
||||||
|
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` or `ssh-key` is set.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Jobs as Kubernetes pods
|
||||||
|
|
||||||
|
A `kubernetes` label runs each job as a pod, without a Docker daemon:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The pod has a `job` container from the label's image or the job's `container.image`, plus one container per service.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
In a cluster, the runner uses its pod's service account and namespace. [`examples/kubernetes/kubernetes-pods.yaml`](../examples/kubernetes/kubernetes-pods.yaml) deploys a runner with these permissions:
|
||||||
|
|
||||||
|
| Resource | Verbs |
|
||||||
|
| --- | --- |
|
||||||
|
| `pods` | `create`, `get`, `list`, `delete` |
|
||||||
|
| `pods/exec` | `create`, `get` |
|
||||||
|
| `pods/log` | `get` |
|
||||||
|
| `secrets` | `create`, `delete` |
|
||||||
|
|
||||||
|
Outside a cluster, it uses the current context of the first file in `$KUBECONFIG`, or of `~/.kube/config`, which must authenticate with a token, token file or client certificate. The cluster must run Kubernetes 1.30 or later, and job images need `sh`, `tar`, `tail` and `env`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kubernetes:
|
||||||
|
kubeconfig: "" # instead of the service account
|
||||||
|
namespace: "" # defaults to the service account's, the kubeconfig context's, or default
|
||||||
|
pod_template: # merged under each job pod, a container named job merges into the job container, one named $services into each service container
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: job
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4Gi
|
||||||
|
- name: $services
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1Gi
|
||||||
|
pod_templates: # merged over pod_template for jobs whose runs-on has the label they are keyed by
|
||||||
|
gpu:
|
||||||
|
spec:
|
||||||
|
nodeSelector:
|
||||||
|
nvidia.com/gpu.present: "true"
|
||||||
|
```
|
||||||
|
|
||||||
|
A job with `runs-on: [linux, gpu]` gets the `linux` and then the `gpu` entry of `pod_templates` merged over `pod_template`, while its image comes from the first of its labels the runner has. A template volume named `workspace` or `act` replaces the `emptyDir` the runner mounts at the workspace or at `/var/run/act`, for example to limit its size. Images are pulled by the cluster's `imagePullPolicy` default, a template's, or always with `container.force_pull`. Like every config value, templates expand `${NAME}` from the runner's environment, so a shell command in a template writes `$${NAME}` for the pod's own variable.
|
||||||
|
|
||||||
|
Job pods mount no service account token unless the template sets `automountServiceAccountToken: true`. Job pods and their Secrets are labeled `app.kubernetes.io/managed-by: gitea-runner` and `com.gitea.runner.uuid: <runner UUID>`. The idle cleanup removes those the runner did not get to remove, once they are older than `runner.workdir_cleanup_age`.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
Services share the pod's network, so a step reaches one by its id or `localhost`, and two services cannot listen on the same port. An id is lowercased and has characters other than letters, digits and `-` replaced by `-`, so the service `my_db` is reached as `my-db`. Pass secrets to services through `env`, as `command` is visible in the pod spec. Of `options`, only `--health-*` is supported. They become a startup probe with Docker's defaults, which stops a service that fails its retries, so the job fails as on Docker.
|
||||||
|
|
||||||
|
## Unsupported
|
||||||
|
|
||||||
|
Docker actions, `docker://` steps, and the `options`, `volumes` and `credentials` of the job container fail the job, as do `volumes` and `credentials` of services. Run such workflows on a [Docker in Docker](../examples/kubernetes/README.md) runner. Pull private images with `imagePullSecrets` in `pod_template`.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# OpenTelemetry
|
||||||
|
|
||||||
|
The runner can send a trace of every job it runs, and its metrics, 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. Export 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` and metrics to `/v1/metrics` under it |
|
||||||
|
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Full URL per signal, takes precedence over the base URL |
|
||||||
|
| `OTEL_EXPORTER_OTLP_HEADERS` | Request headers such as an API key, as comma-separated `key=value` pairs with URL-encoded values |
|
||||||
|
| `OTEL_EXPORTER_OTLP_TIMEOUT` | Export timeout in milliseconds, default 10000, 0 for none |
|
||||||
|
| `OTEL_EXPORTER_OTLP_COMPRESSION` | `gzip` or `none`, default `none` |
|
||||||
|
| `OTEL_EXPORTER_OTLP_CERTIFICATE` | PEM file of the CA to trust instead of the system ones |
|
||||||
|
| `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE`, `OTEL_EXPORTER_OTLP_CLIENT_KEY` | PEM files of the client certificate and key for mTLS |
|
||||||
|
| `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_METRIC_EXPORT_INTERVAL`, `OTEL_METRIC_EXPORT_TIMEOUT` | Metrics export interval and timeout in milliseconds, default 60000 and 30000, a timeout of 0 for none |
|
||||||
|
| `OTEL_SDK_DISABLED=true` | Turn all export off |
|
||||||
|
| `OTEL_TRACES_EXPORTER=none`, `OTEL_METRICS_EXPORTER=none` | Turn traces or metrics off |
|
||||||
|
|
||||||
|
Except for the endpoint, each `OTEL_EXPORTER_OTLP_*` variable has `TRACES` and `METRICS` variants such as `OTEL_EXPORTER_OTLP_METRICS_HEADERS`, which take precedence for that signal. Only the `otlp` exporter over 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, version, process ID and start time, and the Go 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
|
||||||
|
|
||||||
|
The metrics of the Prometheus endpoint are also exported, whether or not `metrics.enabled` is set, except `gitea_runner_info`, `go_info` and `process_start_time_seconds`, which are sent as resource attributes. They are sent every export interval and once more on shutdown. To turn them off, set `OTEL_METRICS_EXPORTER=none`.
|
||||||
|
|
||||||
|
Metrics are named after the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/) where one exists, and under `gitea.runner.*` otherwise. Other Go and process metrics, such as `go_memstats_heap_alloc_bytes`, keep their Prometheus names.
|
||||||
|
|
||||||
|
| Metric | Attributes |
|
||||||
|
| --- | --- |
|
||||||
|
| `cicd.worker.count` | `cicd.worker.state` |
|
||||||
|
| `cicd.system.errors` | `cicd.system.component`, `rpc.method`, `error.type` |
|
||||||
|
| `gitea.runner.jobs` | `cicd.pipeline.result` |
|
||||||
|
| `gitea.runner.job.active`, `gitea.runner.job.limit`, `gitea.runner.job.utilization`, `gitea.runner.job.duration` | |
|
||||||
|
| `gitea.runner.poll.fetches` | `gitea.runner.poll.result` |
|
||||||
|
| `gitea.runner.uptime`, `gitea.runner.poll.backoff`, `gitea.runner.poll.fetch.duration` | |
|
||||||
|
| `gitea.runner.report.log.requests`, `gitea.runner.report.state.requests` | `gitea.runner.report.result` |
|
||||||
|
| `gitea.runner.report.log.buffer`, `gitea.runner.report.log.duration`, `gitea.runner.report.state.duration` | |
|
||||||
|
| `go.memory.used` | `go.memory.type` |
|
||||||
|
| `go.goroutine.count`, `go.memory.allocated`, `go.memory.gc.goal`, `go.memory.gc.cycles`, `go.memory.limit`, `go.config.gogc`, `go.processor.limit` | |
|
||||||
|
| `process.memory.usage`, `process.memory.virtual`, `process.unix.file_descriptor.count` (`process.windows.handle.count` on Windows) | |
|
||||||
|
|
||||||
|
## 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` or a client certificate. Over plain `http`, headers travel unencrypted.
|
||||||
|
- TLS certificates are verified against the system CA bundle, or only against `OTEL_EXPORTER_OTLP_CERTIFICATE` when set. 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.
|
||||||
+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):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func testBuiltinCheckout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
api, repo := newScenario(t)
|
||||||
|
pushWorkflow(t, api, repo, "checkout.yml")
|
||||||
|
branchRun := waitForRun(t, api, repo)
|
||||||
|
if err := api.CreateTag(t.Context(), repo, "v1"); err != nil {
|
||||||
|
t.Fatalf("create tag: %v", err)
|
||||||
|
}
|
||||||
|
tagRun := waitForRunAfter(t, api, repo, branchRun.ID)
|
||||||
|
requireSuccess(t, api, repo, branchRun.ID)
|
||||||
|
requireSuccess(t, api, repo, tagRun.ID)
|
||||||
|
}
|
||||||
@@ -30,7 +30,9 @@ func TestCompatibility(t *testing.T) {
|
|||||||
t.Run("artifact", testArtifactRoundTrip)
|
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("builtin_checkout", testBuiltinCheckout)
|
||||||
t.Run("ephemeral", testEphemeralRunner)
|
t.Run("ephemeral", testEphemeralRunner)
|
||||||
|
t.Run("kubernetes", testKubernetes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPayloads(t *testing.T) {
|
func testPayloads(t *testing.T) {
|
||||||
@@ -69,6 +71,7 @@ func testPayloads(t *testing.T) {
|
|||||||
"cell-a-2",
|
"cell-a-2",
|
||||||
"cell-b-1",
|
"cell-b-1",
|
||||||
"cell-b-2",
|
"cell-b-2",
|
||||||
|
"imageless env=imageless-env-value host=imageless-host",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(logs, want) {
|
if !strings.Contains(logs, want) {
|
||||||
t.Errorf("stored logs are missing %q", want)
|
t.Errorf("stored logs are missing %q", want)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func testEphemeralRunner(t *testing.T) {
|
|||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("ephemeral runner was not deleted")
|
t.Fatal("ephemeral runner was not deleted")
|
||||||
}
|
}
|
||||||
if !poller.Unregistered() {
|
if !poller.RegistrationRejected() {
|
||||||
t.Fatal("ephemeral runner stopped without being deleted")
|
t.Fatal("ephemeral runner stopped without being deleted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,12 @@ func (a *GiteaAPI) CreateFile(ctx context.Context, repo, path, content, message
|
|||||||
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *GiteaAPI) CreateTag(ctx context.Context, repo, name string) error {
|
||||||
|
body := map[string]any{"tag_name": name}
|
||||||
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/tags", giteaAdminUser, repo)
|
||||||
|
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
|
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
|
||||||
body := map[string]any{"data": value}
|
body := map[string]any{"data": value}
|
||||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
|
||||||
|
|||||||
+19
-6
@@ -23,6 +23,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
apicontainer "github.com/moby/moby/api/types/container"
|
apicontainer "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"
|
||||||
@@ -187,6 +188,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
|||||||
"GITEA__security__INSTALL_LOCK=true",
|
"GITEA__security__INSTALL_LOCK=true",
|
||||||
"GITEA__database__DB_TYPE=sqlite3",
|
"GITEA__database__DB_TYPE=sqlite3",
|
||||||
"GITEA__actions__ENABLED=true",
|
"GITEA__actions__ENABLED=true",
|
||||||
|
"GITEA__server__LFS_START_SERVER=true",
|
||||||
"GITEA__server__ROOT_URL=" + baseURL + "/",
|
"GITEA__server__ROOT_URL=" + baseURL + "/",
|
||||||
},
|
},
|
||||||
ExposedPorts: network.PortSet{containerPort: struct{}{}},
|
ExposedPorts: network.PortSet{containerPort: struct{}{}},
|
||||||
@@ -306,9 +308,14 @@ func randomToken(n int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) {
|
func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) {
|
||||||
created, err := f.cli.ExecCreate(ctx, f.id, mobyclient.ExecCreateOptions{
|
return containerExec(ctx, f.cli, f.id, "git", nil, cmd...) // gitea refuses root
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user string, stdin io.Reader, cmd ...string) (string, error) {
|
||||||
|
created, err := cli.ExecCreate(ctx, id, mobyclient.ExecCreateOptions{
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
User: "git", // gitea refuses root
|
User: user,
|
||||||
|
AttachStdin: stdin != nil,
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
})
|
})
|
||||||
@@ -316,18 +323,24 @@ func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
attached, err := f.cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{})
|
attached, err := cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer attached.Close()
|
defer attached.Close()
|
||||||
|
if stdin != nil {
|
||||||
|
go func() {
|
||||||
|
_, _ = io.Copy(attached.Conn, stdin)
|
||||||
|
_ = attached.CloseWrite()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
if _, err := io.Copy(&out, attached.Reader); err != nil {
|
if _, err := stdcopy.StdCopy(&out, &out, attached.Reader); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
inspected, err := f.cli.ExecInspect(ctx, created.ID, mobyclient.ExecInspectOptions{})
|
inspected, err := cli.ExecInspect(ctx, created.ID, mobyclient.ExecInspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -360,6 +373,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())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build e2e
|
||||||
|
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
|
apicontainer "github.com/moby/moby/api/types/container"
|
||||||
|
"github.com/moby/moby/api/types/network"
|
||||||
|
mobyclient "github.com/moby/moby/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testKubernetes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := t.Context()
|
||||||
|
k3sID, kubernetes := startK3s(t)
|
||||||
|
|
||||||
|
images := preloadImages(t, k3sID, os.Getenv("E2E_JOB_IMAGE"), os.Getenv("SERVICE_IMAGE"))
|
||||||
|
|
||||||
|
api, repo := newScenario(t)
|
||||||
|
if err := api.CreateVariable(ctx, repo, "E2E_SERVICE_IMAGE", images[1]); err != nil {
|
||||||
|
t.Fatalf("create service image variable: %v", err)
|
||||||
|
}
|
||||||
|
startRunner(t, repo, "e2e-kubernetes", runnerOptions{image: images[0], kubernetes: kubernetes})
|
||||||
|
pushWorkflow(t, api, repo, "kubernetes.yml")
|
||||||
|
|
||||||
|
wfRun := waitForRun(t, api, repo)
|
||||||
|
requireSuccess(t, api, repo, wfRun.ID)
|
||||||
|
|
||||||
|
if out, err := containerExec(ctx, fixture.cli, k3sID, "", nil,
|
||||||
|
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
|
||||||
|
); err != nil {
|
||||||
|
t.Errorf("job pods or secrets left behind: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startK3s(t *testing.T) (string, *config.Kubernetes) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := t.Context()
|
||||||
|
cli := fixture.cli
|
||||||
|
|
||||||
|
name := fmt.Sprintf("gitea-runner-e2e-k3s-%d", time.Now().UnixNano())
|
||||||
|
apiPort := network.MustParsePort("6443/tcp")
|
||||||
|
server := "https://" + name + ":6443"
|
||||||
|
hostConfig := &apicontainer.HostConfig{Privileged: true}
|
||||||
|
var netConfig *network.NetworkingConfig
|
||||||
|
if fixture.network != "" {
|
||||||
|
netConfig = &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{fixture.network: {}}}
|
||||||
|
} else {
|
||||||
|
loopback := netip.MustParseAddr("127.0.0.1")
|
||||||
|
port, err := freeHostPort(loopback.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("find a free host port: %v", err)
|
||||||
|
}
|
||||||
|
server = "https://" + net.JoinHostPort(loopback.String(), strconv.Itoa(port))
|
||||||
|
hostConfig.PortBindings = network.PortMap{apiPort: []network.PortBinding{{HostIP: loopback, HostPort: strconv.Itoa(port)}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := cli.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
|
||||||
|
Config: &apicontainer.Config{
|
||||||
|
Image: os.Getenv("E2E_K3S_IMAGE"),
|
||||||
|
Cmd: []string{"server", "--disable=traefik,metrics-server,servicelb,local-storage,coredns", "--tls-san=" + name, "--kubelet-arg=fail-cgroupv1=false"},
|
||||||
|
ExposedPorts: network.PortSet{apiPort: struct{}{}},
|
||||||
|
},
|
||||||
|
HostConfig: hostConfig,
|
||||||
|
NetworkingConfig: netConfig,
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create k3s container: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = cli.ContainerRemove(context.Background(), created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||||
|
})
|
||||||
|
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
|
||||||
|
t.Fatalf("start k3s container: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
for {
|
||||||
|
_, err := containerExec(waitCtx, cli, created.ID, "", nil, "kubectl", "get", "serviceaccount", "default")
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
|
if inspectErr != nil || !inspected.Container.State.Running || waitCtx.Err() != nil {
|
||||||
|
var logs bytes.Buffer
|
||||||
|
if reader, logsErr := cli.ContainerLogs(ctx, created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
|
||||||
|
_, _ = stdcopy.StdCopy(&logs, &logs, reader)
|
||||||
|
_ = reader.Close()
|
||||||
|
}
|
||||||
|
t.Fatalf("k3s did not start: %v, state %+v %v, last log lines:\n%s", err, inspected.Container.State, inspectErr, logs.String())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-waitCtx.Done():
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kubeconfig, err := containerExec(ctx, cli, created.ID, "", nil, "cat", "/etc/rancher/k3s/k3s.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read kubeconfig: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "kubeconfig")
|
||||||
|
if err := os.WriteFile(path, []byte(strings.ReplaceAll(kubeconfig, "https://127.0.0.1:6443", server)), 0o600); err != nil {
|
||||||
|
t.Fatalf("write kubeconfig: %v", err)
|
||||||
|
}
|
||||||
|
kubernetes := &config.Kubernetes{Kubeconfig: path, Namespace: "default"}
|
||||||
|
|
||||||
|
if fixture.network != "" {
|
||||||
|
gitea, err := cli.ContainerInspect(ctx, fixture.id, mobyclient.ContainerInspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect gitea container: %v", err)
|
||||||
|
}
|
||||||
|
giteaURL, err := url.Parse(fixture.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse gitea url: %v", err)
|
||||||
|
}
|
||||||
|
kubernetes.PodTemplate = map[string]any{"spec": map[string]any{"hostAliases": []any{map[string]any{
|
||||||
|
"ip": gitea.Container.NetworkSettings.Networks[fixture.network].IPAddress.String(),
|
||||||
|
"hostnames": []any{giteaURL.Hostname()},
|
||||||
|
}}}}
|
||||||
|
}
|
||||||
|
return created.ID, kubernetes
|
||||||
|
}
|
||||||
|
|
||||||
|
func preloadImages(t *testing.T, k3sID string, images ...string) []string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := t.Context()
|
||||||
|
var aliases []string
|
||||||
|
for index, image := range images {
|
||||||
|
alias := fmt.Sprintf("gitea-runner-e2e/preload-%d:%d", index, time.Now().UnixNano())
|
||||||
|
if _, err := fixture.cli.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
|
||||||
|
t.Fatalf("tag %s: %v", image, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = fixture.cli.ImageRemove(context.Background(), alias, mobyclient.ImageRemoveOptions{}) })
|
||||||
|
aliases = append(aliases, alias)
|
||||||
|
}
|
||||||
|
archive, err := fixture.cli.ImageSave(ctx, aliases)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save %v: %v", images, err)
|
||||||
|
}
|
||||||
|
defer archive.Close()
|
||||||
|
if out, err := containerExec(ctx, fixture.cli, k3sID, "", archive, "ctr", "--namespace=k8s.io", "images", "import", "-"); err != nil {
|
||||||
|
t.Fatalf("import %v into k3s: %v: %s", images, err, out)
|
||||||
|
}
|
||||||
|
return aliases
|
||||||
|
}
|
||||||
+6
-1
@@ -141,6 +141,11 @@ func dumpRunLogs(t *testing.T, api *GiteaAPI, repo string, runID int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
||||||
|
t.Helper()
|
||||||
|
return waitForRunAfter(t, api, repo, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForRunAfter(t *testing.T, api *GiteaAPI, repo string, previousID int64) *ActionRun {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -150,7 +155,7 @@ func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get latest run: %v", err)
|
t.Fatalf("get latest run: %v", err)
|
||||||
}
|
}
|
||||||
if len(runs) > 0 {
|
if len(runs) > 0 && runs[0].ID > previousID {
|
||||||
return &runs[0]
|
return &runs[0]
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
+14
-7
@@ -6,6 +6,7 @@
|
|||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
@@ -24,10 +25,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type runnerOptions struct {
|
type runnerOptions struct {
|
||||||
capacity int
|
capacity int
|
||||||
ephemeral bool
|
ephemeral bool
|
||||||
cacheV2 *bool
|
cacheV2 *bool
|
||||||
cacheHost string
|
cacheHost string
|
||||||
|
image string
|
||||||
|
kubernetes *config.Kubernetes
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
||||||
@@ -61,14 +64,18 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
|||||||
cfg.Container.Network = fixture.network
|
cfg.Container.Network = fixture.network
|
||||||
}
|
}
|
||||||
|
|
||||||
rawLabel := labelName + ":docker://" + os.Getenv("E2E_JOB_IMAGE")
|
scheme := labels.SchemeDocker
|
||||||
|
if options.kubernetes != nil {
|
||||||
|
scheme, cfg.Kubernetes = labels.SchemeKubernetes, *options.kubernetes
|
||||||
|
}
|
||||||
|
rawLabel := labelName + ":" + scheme + "://" + cmp.Or(options.image, os.Getenv("E2E_JOB_IMAGE"))
|
||||||
label, err := labels.Parse(rawLabel)
|
label, err := labels.Parse(rawLabel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parse label %q: %v", rawLabel, err)
|
t.Fatalf("parse label %q: %v", rawLabel, err)
|
||||||
}
|
}
|
||||||
labelNames := []string{label.Name}
|
labelNames := []string{label.Name}
|
||||||
|
|
||||||
pingCli := client.New(fixture.baseURL, cfg.Runner.Insecure, "", "", config.RequestTimeout)
|
pingCli := client.New(fixture.baseURL, cfg.Runner.TLSConfig(), "", "", config.RequestTimeout, cfg.Runner.ExtraHeaders)
|
||||||
if _, err := pingCli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Data: t.Name()})); err != nil {
|
if _, err := pingCli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Data: t.Name()})); err != nil {
|
||||||
t.Fatalf("ping %s: %v", fixture.baseURL, err)
|
t.Fatalf("ping %s: %v", fixture.baseURL, err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +105,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
|||||||
Ephemeral: regResp.Msg.Runner.Ephemeral,
|
Ephemeral: regResp.Msg.Runner.Ephemeral,
|
||||||
}
|
}
|
||||||
|
|
||||||
cli := client.New(fixture.baseURL, cfg.Runner.Insecure, reg.UUID, reg.Token, config.RequestTimeout)
|
cli := client.New(fixture.baseURL, cfg.Runner.TLSConfig(), reg.UUID, reg.Token, config.RequestTimeout, cfg.Runner.ExtraHeaders)
|
||||||
|
|
||||||
runner := run.NewRunner(cfg, reg, cli)
|
runner := run.NewRunner(cfg, reg, cli)
|
||||||
declResp, err := runner.Declare(ctx, labelNames)
|
declResp, err := runner.Declare(ctx, labelNames)
|
||||||
|
|||||||
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
|
||||||
Vendored
+61
@@ -0,0 +1,61 @@
|
|||||||
|
name: checkout
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
checkout:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- id: checkout
|
||||||
|
uses: builtin:checkout
|
||||||
|
- uses: builtin:checkout
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref_name }}
|
||||||
|
path: short
|
||||||
|
- run: test "$(git -C short rev-parse HEAD)" = "$GITHUB_SHA"
|
||||||
|
- if: github.ref_type == 'branch'
|
||||||
|
run: |
|
||||||
|
test "$(git symbolic-ref HEAD)" = "$GITHUB_REF"
|
||||||
|
test "${{ steps.checkout.outputs.ref }}" = "$GITHUB_REF"
|
||||||
|
test "${{ steps.checkout.outputs.commit }}" = "$GITHUB_SHA"
|
||||||
|
git -c user.name=e2e -c [email protected] commit --allow-empty -m pushed
|
||||||
|
git push
|
||||||
|
git tag moved
|
||||||
|
git push --force origin moved
|
||||||
|
git switch --orphan child
|
||||||
|
echo child > child.txt
|
||||||
|
git add child.txt
|
||||||
|
git -c user.name=e2e -c [email protected] commit -m child
|
||||||
|
git push origin child
|
||||||
|
git switch -c content main
|
||||||
|
git submodule add -b child "../${{ github.event.repository.name }}.git" sub
|
||||||
|
git lfs install --local
|
||||||
|
git lfs track '*.bin'
|
||||||
|
mkdir keep drop
|
||||||
|
echo big > keep/big.bin
|
||||||
|
echo big > drop/big.bin
|
||||||
|
git add .gitattributes keep drop
|
||||||
|
git -c user.name=e2e -c [email protected] commit -m content
|
||||||
|
git push origin content
|
||||||
|
- if: github.ref_type == 'branch'
|
||||||
|
uses: builtin:checkout
|
||||||
|
with:
|
||||||
|
ref: content
|
||||||
|
path: full
|
||||||
|
submodules: true
|
||||||
|
lfs: true
|
||||||
|
- if: github.ref_type == 'branch'
|
||||||
|
uses: builtin:checkout
|
||||||
|
with:
|
||||||
|
ref: content
|
||||||
|
path: sparse
|
||||||
|
sparse-checkout: keep
|
||||||
|
lfs: true
|
||||||
|
- if: github.ref_type == 'branch'
|
||||||
|
run: |
|
||||||
|
test "$(cat full/sub/child.txt)" = child
|
||||||
|
test "$(cat full/keep/big.bin)" = big
|
||||||
|
test "$(cat sparse/keep/big.bin)" = big
|
||||||
|
test ! -e sparse/drop
|
||||||
|
- if: github.ref_type == 'tag'
|
||||||
|
run: test "$(git describe --tags)" = "$GITHUB_REF_NAME"
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
name: kubernetes
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
pod:
|
||||||
|
runs-on: e2e-kubernetes
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: ${{ vars.E2E_SERVICE_IMAGE }}
|
||||||
|
env:
|
||||||
|
NGINX_ENTRYPOINT_QUIET_LOGS: 1
|
||||||
|
options: --health-cmd "wget -q -O /dev/null http://localhost/" --health-interval 1s
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: |
|
||||||
|
test -f .gitea/workflows/kubernetes.yml
|
||||||
|
echo "FROM_ENV=exported" >> "$GITHUB_ENV"
|
||||||
|
- run: |
|
||||||
|
test "$FROM_ENV" = exported
|
||||||
|
test -n "$KUBERNETES_SERVICE_HOST" -a -n "${{ runner.arch }}"
|
||||||
|
curl -fsS -o /dev/null http://web/
|
||||||
Vendored
+5
@@ -3,6 +3,10 @@ on: push
|
|||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
env:
|
||||||
|
IMAGELESS_ENV: imageless-env-value
|
||||||
|
options: --hostname imageless-host
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: ${{ vars.E2E_SERVICE_IMAGE }}
|
image: ${{ vars.E2E_SERVICE_IMAGE }}
|
||||||
@@ -19,6 +23,7 @@ jobs:
|
|||||||
echo '::notice::encoded-first%0Aencoded-second'
|
echo '::notice::encoded-first%0Aencoded-second'
|
||||||
echo "the variable is ${{ vars.GREETING }}"
|
echo "the variable is ${{ vars.GREETING }}"
|
||||||
echo "the secret is ${{ secrets.FOO }}"
|
echo "the secret is ${{ secrets.FOO }}"
|
||||||
|
echo "imageless env=$IMAGELESS_ENV host=$(hostname)"
|
||||||
curl --connect-timeout 1 --max-time 2 --retry 30 --retry-delay 1 --retry-max-time 30 --retry-all-errors -fsS -o /dev/null http://web/
|
curl --connect-timeout 1 --max-time 2 --retry 30 --retry-delay 1 --retry-max-time 30 --retry-all-errors -fsS -o /dev/null http://web/
|
||||||
produce:
|
produce:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
### Running `gitea-runner` using `docker-compose`
|
### Running `gitea-runner` using `docker-compose`
|
||||||
|
|
||||||
|
This uses the recommended Docker-in-Docker image, see [image flavours](../../README.md#image-flavours).
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
...
|
...
|
||||||
gitea:
|
gitea:
|
||||||
@@ -19,8 +21,9 @@
|
|||||||
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
|
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
|
||||||
|
|
||||||
runner:
|
runner:
|
||||||
image: gitea/runner
|
image: gitea/runner:latest-dind
|
||||||
restart: always
|
restart: always
|
||||||
|
privileged: true
|
||||||
depends_on:
|
depends_on:
|
||||||
gitea:
|
gitea:
|
||||||
# required so runner can attach to gitea, see "healthcheck"
|
# required so runner can attach to gitea, see "healthcheck"
|
||||||
@@ -28,7 +31,6 @@
|
|||||||
restart: true
|
restart: true
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/runner:/data
|
- ./data/runner:/data
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
environment:
|
environment:
|
||||||
- GITEA_INSTANCE_URL=<instance url>
|
- GITEA_INSTANCE_URL=<instance url>
|
||||||
# When using Docker Secrets, it's also possible to use
|
# When using Docker Secrets, it's also possible to use
|
||||||
@@ -38,10 +40,9 @@
|
|||||||
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration token>
|
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration token>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running `gitea-runner` using Docker-in-Docker (DIND)
|
### Running `gitea-runner` using rootless Docker-in-Docker
|
||||||
|
|
||||||
- `privileged` has to be set to `true` because in-container Docker daemon requires a lot of kernel capabilities and file system mounts like `procfs` and `sysfs`
|
On AppArmor hosts such as Ubuntu and Debian, `security_opt` is needed for the daemon to start. Docker's `docker-ce-rootless-extras` package provides the `rootlesskit` profile.
|
||||||
- `security_opt` sets the `apparmor` profile to `rootlesskit` for hosts running AppArmor (e.g. Ubuntu, Debian), where the kernel might otherwise block user namespace changes that Docker daemon requires for startup. The `rootlesskit` profile is provided by the `docker-ce-rootless-extras` package and is present on hosts where Docker was installed via the official installer or distro packages
|
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
### Run `gitea-runner` in a Docker Container
|
### Run `gitea-runner` in a Docker Container
|
||||||
|
|
||||||
|
The recommended Docker-in-Docker image runs its own Docker daemon for jobs, so they never get the host's Docker socket:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/data:/data --name my_runner gitea/runner:nightly
|
docker run --privileged \
|
||||||
|
-e GITEA_INSTANCE_URL=http://192.168.8.18:3000 \
|
||||||
|
-e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> \
|
||||||
|
-v "$PWD/data:/data" \
|
||||||
|
-v runner-docker:/var/lib/docker \
|
||||||
|
--name my_runner gitea/runner:nightly-dind
|
||||||
```
|
```
|
||||||
|
|
||||||
The `/data` directory inside the docker container contains the runner API keys after registration.
|
The volumes keep the runner's registration and the job image cache. See [image flavours](../../README.md#image-flavours) for alternatives.
|
||||||
It must be persisted, otherwise the runner would try to register again, using the same, now defunct registration token.
|
|
||||||
@@ -1,25 +1,8 @@
|
|||||||
## Kubernetes Docker in Docker Deployment with `gitea-runner`
|
## Kubernetes deployments of `gitea-runner`
|
||||||
|
|
||||||
NOTE: Docker in Docker (dind) requires elevated privileges on Kubernetes. The current way to achieve this is to set the pod `SecurityContext` to `privileged`. Keep in mind that this is a potential security issue that has the potential for a malicious application to break out of the container context.
|
Jobs can run on Kubernetes in two ways:
|
||||||
|
|
||||||
NOTE: `dind-docker.yaml` uses the native sidecar pattern (init container with `restartPolicy: Always`), which requires Kubernetes 1.29+ (or 1.28 with the `SidecarContainers` feature gate).
|
- **Job pods**: each job runs as its own pod, without privileged containers and under the cluster's quotas and policies. Docker actions and `docker://` steps are not supported. Deploy with [`kubernetes-pods.yaml`](kubernetes-pods.yaml) and see [docs/kubernetes.md](../../docs/kubernetes.md).
|
||||||
|
- **Docker in Docker**: jobs run in a Docker daemon next to the runner. Every workflow feature works, but the daemon is privileged, so a job can break out to the node. Deploy with [`dind-docker.yaml`](dind-docker.yaml), [`statefulset-dind.yaml`](statefulset-dind.yaml) for several replicas, [`rootless-docker.yaml`](rootless-docker.yaml) for a rootless daemon, or the [Helm chart](https://gitea.com/gitea/helm-actions). The sidecars in the first two need Kubernetes 1.29 or later.
|
||||||
|
|
||||||
NOTE: A helm chart for `gitea-runner` also exists for easier deployments https://gitea.com/gitea/helm-actions
|
All examples read the registration token from the `runner-secret` Secret and the Gitea URL from `GITEA_INSTANCE_URL`, and keep the registration in `/data`. They give a stopping pod three hours to finish its jobs, so keep `runner.shutdown_timeout` below `terminationGracePeriodSeconds`.
|
||||||
|
|
||||||
Each example persists **two** things, and it is worth knowing which is which:
|
|
||||||
|
|
||||||
- `/data` is the runner's working directory. It holds the `.runner` registration file and, optionally, the config file — so the runner re-attaches to the server instead of registering again.
|
|
||||||
- The Docker daemon's data root holds the images pulled for jobs (`/var/lib/docker` for the dind sidecar, `/home/rootless/.local/share/docker` for `dind-rootless`). It is *not* under `/data`. If you drop this volume, the examples still work, but the image cache is discarded whenever the pod is recreated and every job re-pulls its images.
|
|
||||||
|
|
||||||
- Kubernetes SIGKILLs a pod 30s after SIGTERM by default, long before a job finishes and reports its result, which leaves tasks the server can only reap as zombies. The manifests raise `terminationGracePeriodSeconds` to three hours, matching the systemd example and the `runner.timeout` job ceiling; set `runner.shutdown_timeout` below that so the runner drains jobs within the window rather than being killed mid-cleanup.
|
|
||||||
|
|
||||||
Files in this directory:
|
|
||||||
|
|
||||||
- [`dind-docker.yaml`](dind-docker.yaml)
|
|
||||||
How to create a Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
|
|
||||||
|
|
||||||
- [`rootless-docker.yaml`](rootless-docker.yaml)
|
|
||||||
How to create a rootless Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
|
|
||||||
|
|
||||||
- [`statefulset-dind.yaml`](statefulset-dind.yaml)
|
|
||||||
StatefulSet variant of the dind example. Each replica gets a stable identity and its own persistent volume via `volumeClaimTemplates`, so the runner keeps its `.runner` registration across restarts and reschedules instead of trying to register again.
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
data:
|
||||||
|
token: << base64 encoded registration token >>
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: runner-secret
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: runner
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: runner
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods"]
|
||||||
|
verbs: ["create", "get", "list", "delete"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods/exec"]
|
||||||
|
verbs: ["create", "get"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods/log"]
|
||||||
|
verbs: ["get"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["secrets"]
|
||||||
|
verbs: ["create", "delete"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: runner
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: runner
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: runner
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: runner
|
||||||
|
spec:
|
||||||
|
serviceName: runner
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: runner
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: runner
|
||||||
|
spec:
|
||||||
|
serviceAccountName: runner
|
||||||
|
terminationGracePeriodSeconds: 10800 # keep above runner.shutdown_timeout, see README
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea/runner:nightly
|
||||||
|
env:
|
||||||
|
- name: GITEA_INSTANCE_URL
|
||||||
|
value: http://gitea-http.gitea.svc.cluster.local:3000
|
||||||
|
- name: GITEA_RUNNER_REGISTRATION_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: runner-secret
|
||||||
|
key: token
|
||||||
|
- name: GITEA_RUNNER_LABELS
|
||||||
|
value: ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest
|
||||||
|
volumeMounts:
|
||||||
|
- name: runner-data
|
||||||
|
mountPath: /data
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: runner-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -5,27 +5,29 @@ go 1.27
|
|||||||
toolchain go1.27.1
|
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.3.0
|
||||||
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.2
|
||||||
|
github.com/coder/websocket v1.8.15
|
||||||
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.2+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.2
|
||||||
github.com/go-git/go-git/v5 v5.19.2
|
github.com/go-git/go-git/v5 v5.19.2
|
||||||
github.com/gobwas/glob v1.0.0
|
|
||||||
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
|
||||||
@@ -37,15 +39,20 @@ require (
|
|||||||
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 +60,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 +74,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 +89,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.3.0 h1:xZRoTL1+sK/PaglT9uXkZJ9g0nk1WLMEH4FUXdXpKKw=
|
||||||
gitea.dev/actionslib v0.7.0/go.mod h1:DI3Lqp+8TrycM7/semMdqsDQOHaBskjIAuMn+SXfZJ0=
|
gitea.dev/actionslib v1.3.0/go.mod h1:svCefEsavx4jmn8vJ4wTG7Q0KBcGfrFmGUjrhxuNUAQ=
|
||||||
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,16 +19,44 @@ 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.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.10.2/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/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
@@ -44,8 +72,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.2+incompatible h1:2zgdFuoFst2T80oS42vhKGxkd0JRo305eIEKTsxR7pQ=
|
||||||
|
github.com/docker/cli v29.8.2+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,16 +86,14 @@ 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=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
github.com/go-git/go-billy/v5 v5.9.1 h1:8U73XiOTfINdItHVa6z4Gv7ToObcZ6grkqQbLryLCdA=
|
github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE=
|
||||||
github.com/go-git/go-billy/v5 v5.9.1/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0=
|
github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
|
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
|
||||||
@@ -77,8 +105,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 v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
|
|
||||||
github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
|
|
||||||
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 +117,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 +140,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=
|
||||||
@@ -163,13 +185,13 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi
|
|||||||
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=
|
||||||
@@ -177,6 +199,8 @@ github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBB
|
|||||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
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 +212,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 +222,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 +230,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 +253,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 +273,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 +303,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=
|
||||||
@@ -29,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
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ func loadConfigCmd(configFile *string) *cobra.Command {
|
|||||||
|
|
||||||
configCmd.AddCommand(loadGenerateConfigCmd("generate"))
|
configCmd.AddCommand(loadGenerateConfigCmd("generate"))
|
||||||
configCmd.AddCommand(loadInitConfigCmd(configFile))
|
configCmd.AddCommand(loadInitConfigCmd(configFile))
|
||||||
|
configCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "runner-file",
|
||||||
|
Short: "Print the registration file path used by the runner",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := config.LoadDefault(*configFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), cfg.Runner.File)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
configCmd.AddCommand(&cobra.Command{
|
configCmd.AddCommand(&cobra.Command{
|
||||||
Use: "get <key>",
|
Use: "get <key>",
|
||||||
|
|||||||
@@ -97,3 +97,14 @@ func TestConfigCmdResolvesTheConfigFile(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "--config")
|
assert.Contains(t, err.Error(), "--config")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigCmdRunnerFileUsesConfiguredPathOrDefault(t *testing.T) {
|
||||||
|
out, _, err := runConfigCmd(t, "", "runner-file")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ".runner\n", out)
|
||||||
|
file := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte("runner:\n file: 'state dir/registration.json'\n"), 0o600))
|
||||||
|
out, _, err = runConfigCmd(t, file, "runner-file")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "state dir/registration.json\n", out)
|
||||||
|
}
|
||||||
@@ -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, metrics.Registry)
|
||||||
|
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{}
|
||||||
@@ -157,10 +170,11 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
|
|
||||||
cli := client.New(
|
cli := client.New(
|
||||||
reg.Address,
|
reg.Address,
|
||||||
cfg.Runner.Insecure,
|
cfg.Runner.TLSConfig(),
|
||||||
reg.UUID,
|
reg.UUID,
|
||||||
reg.Token,
|
reg.Token,
|
||||||
config.RequestTimeout,
|
config.RequestTimeout,
|
||||||
|
cfg.Runner.ExtraHeaders,
|
||||||
)
|
)
|
||||||
|
|
||||||
runner := run.NewRunner(cfg, reg, cli)
|
runner := run.NewRunner(cfg, reg, cli)
|
||||||
@@ -178,6 +192,9 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
return err
|
return err
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.WithError(err).Error("fail to invoke Declare")
|
log.WithError(err).Error("fail to invoke Declare")
|
||||||
|
if client.IsRegistrationRejected(err) {
|
||||||
|
removeEphemeralRegistration(reg, cfg.Runner.File)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
default:
|
default:
|
||||||
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
||||||
@@ -187,12 +204,21 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
|
|
||||||
poller := poll.New(cfg, cli, runner)
|
poller := poll.New(cfg, cli, runner)
|
||||||
|
|
||||||
|
metrics.Init() // OTLP exports the registry even without the server
|
||||||
|
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
|
||||||
|
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
|
||||||
|
metrics.RegisterUptimeFunc(time.Now())
|
||||||
|
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
|
||||||
|
metrics.RegisterStateFunc(func() string {
|
||||||
|
if runner.RunningCount() > 0 {
|
||||||
|
return metrics.LabelStateBusy
|
||||||
|
}
|
||||||
|
if ready, _ := poller.Ready(cfg.Metrics.ReadinessGrace); !ready {
|
||||||
|
return metrics.LabelStateUnavailable
|
||||||
|
}
|
||||||
|
return metrics.LabelStateIdle
|
||||||
|
})
|
||||||
if cfg.Metrics.Enabled {
|
if cfg.Metrics.Enabled {
|
||||||
metrics.Init()
|
|
||||||
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
|
|
||||||
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
|
|
||||||
metrics.RegisterUptimeFunc(time.Now())
|
|
||||||
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
|
|
||||||
metrics.StartServer(ctx, cfg.Metrics.Addr, func() (bool, string) {
|
metrics.StartServer(ctx, cfg.Metrics.Addr, func() (bool, string) {
|
||||||
return poller.Ready(cfg.Metrics.ReadinessGrace)
|
return poller.Ready(cfg.Metrics.ReadinessGrace)
|
||||||
})
|
})
|
||||||
@@ -202,7 +228,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
defer close(done)
|
defer close(done)
|
||||||
poller.PollOnce()
|
poller.PollOnce(func() { removeEphemeralRegistration(reg, cfg.Runner.File) })
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// shutdown when we complete a job or cancel is requested
|
// shutdown when we complete a job or cancel is requested
|
||||||
@@ -231,6 +257,10 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
|
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if poller.RegistrationRejected() {
|
||||||
|
removeEphemeralRegistration(reg, cfg.Runner.File)
|
||||||
|
}
|
||||||
|
|
||||||
if poller.Unregistered() {
|
if poller.Unregistered() {
|
||||||
return errors.New("runner is no longer registered with the server; please register it again")
|
return errors.New("runner is no longer registered with the server; please register it again")
|
||||||
}
|
}
|
||||||
@@ -239,6 +269,17 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeEphemeralRegistration(reg *config.Registration, file string) {
|
||||||
|
if !reg.Ephemeral {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Remove(file); err == nil {
|
||||||
|
log.Infof("removed the spent ephemeral registration %q", file)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
log.WithError(err).Warn("failed to remove the spent ephemeral registration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type daemonArgs struct {
|
type daemonArgs struct {
|
||||||
Once bool
|
Once bool
|
||||||
Labels string
|
Labels string
|
||||||
|
|||||||
@@ -4,13 +4,24 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
"gitea.com/gitea/runner/internal/pkg/report"
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
|
"gitea.dev/actionslib/runner/v1/runnerv1connect"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveLabels(t *testing.T) {
|
func TestResolveLabels(t *testing.T) {
|
||||||
@@ -70,3 +81,80 @@ func TestInitLoggingSetsLevelAndCaller(t *testing.T) {
|
|||||||
// act plans a job on this logger, so a live task's secrets have to be masked out of it
|
// act plans a job on this logger, so a live task's secrets have to be masked out of it
|
||||||
require.IsType(t, report.MaskingFormatter(nil), log.StandardLogger().Formatter)
|
require.IsType(t, report.MaskingFormatter(nil), log.StandardLogger().Formatter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var errUnregisteredRunner = errors.New("rpc error: code = Unauthenticated desc = unregistered runner")
|
||||||
|
|
||||||
|
type daemonTestService struct {
|
||||||
|
runnerv1connect.UnimplementedRunnerServiceHandler
|
||||||
|
declareErr, fetchErr error
|
||||||
|
beforeTask bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *daemonTestService) Declare(context.Context, *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) {
|
||||||
|
if s.declareErr != nil {
|
||||||
|
return nil, s.declareErr
|
||||||
|
}
|
||||||
|
return connect.NewResponse(&runnerv1.DeclareResponse{Runner: &runnerv1.Runner{Name: "test"}}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *daemonTestService) FetchTask(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) {
|
||||||
|
if s.fetchErr != nil {
|
||||||
|
return nil, s.fetchErr
|
||||||
|
}
|
||||||
|
if s.beforeTask {
|
||||||
|
s.cancel()
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
return connect.NewResponse(&runnerv1.FetchTaskResponse{Task: &runnerv1.Task{Id: 1, Context: &structpb.Struct{}, WorkflowPayload: []byte("invalid: [")}}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*daemonTestService) UpdateTask(_ context.Context, req *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
|
||||||
|
return connect.NewResponse(&runnerv1.UpdateTaskResponse{State: req.Msg.State}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*daemonTestService) UpdateLog(_ context.Context, req *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
|
||||||
|
return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
ephemeral, beforeTask bool
|
||||||
|
declareErr, fetchErr error
|
||||||
|
removed bool
|
||||||
|
}{
|
||||||
|
{"ephemeral failed task", true, false, nil, nil, true},
|
||||||
|
{"ephemeral interrupted before task", true, true, nil, nil, false},
|
||||||
|
{"persistent once", false, false, nil, nil, false},
|
||||||
|
{"ephemeral rejected on fetch", true, false, nil, errUnregisteredRunner, true},
|
||||||
|
{"ephemeral rejected on fetch with 401", true, false, nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unregistered runner")), true},
|
||||||
|
{"ephemeral proxy 401 on fetch", true, false, nil, connect.NewError(connect.CodeUnauthenticated, errors.New("HTTP status 401 Unauthorized")), false},
|
||||||
|
{"ephemeral rejected on declare", true, false, errUnregisteredRunner, nil, true},
|
||||||
|
{"persistent rejected on declare", false, false, errUnregisteredRunner, nil, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, handler := runnerv1connect.NewRunnerServiceHandler(&daemonTestService{declareErr: tc.declareErr, fetchErr: tc.fetchErr, beforeTask: tc.beforeTask, cancel: cancel})
|
||||||
|
server := httptest.NewServer(http.StripPrefix("/api/actions", handler))
|
||||||
|
defer server.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
regFile := filepath.Join(dir, "registration.json")
|
||||||
|
configFile := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
|
||||||
|
require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral}))
|
||||||
|
err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil)
|
||||||
|
if tc.declareErr == nil && tc.fetchErr == nil {
|
||||||
|
require.NoError(t, err)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
if tc.removed {
|
||||||
|
require.NoFileExists(t, regFile)
|
||||||
|
} else {
|
||||||
|
require.FileExists(t, regFile)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -618,7 +618,7 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
|
|||||||
execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
|
execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
|
||||||
execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log")
|
execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log")
|
||||||
execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
|
execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
|
||||||
execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", config.DefaultImage, "Docker image to use. Use \"-self-hosted\" to run directly on the host.")
|
execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", config.DefaultImage, "Docker image to use. Use \"kubernetes://<image>\" to run it as a pod, \"-self-hosted\" to run directly on the host.")
|
||||||
execCmd.PersistentFlags().StringVarP(&execArg.toolCacheMode, "tool-cache-mode", "", config.ToolCacheModeNone, "What to mount at RUNNER_TOOL_CACHE: none, or shared to reuse one tool cache across runs")
|
execCmd.PersistentFlags().StringVarP(&execArg.toolCacheMode, "tool-cache-mode", "", config.ToolCacheModeNone, "What to mount at RUNNER_TOOL_CACHE: none, or shared to reuse one tool cache across runs")
|
||||||
execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
|
execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
|
||||||
execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.")
|
execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.")
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
goruntime "runtime"
|
goruntime "runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/internal/app/run"
|
"gitea.com/gitea/runner/internal/app/run"
|
||||||
"gitea.com/gitea/runner/internal/pkg/client"
|
"gitea.com/gitea/runner/internal/pkg/client"
|
||||||
@@ -364,34 +363,19 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
|
|||||||
// initial http client
|
// initial http client
|
||||||
cli := client.New(
|
cli := client.New(
|
||||||
inputs.InstanceAddr,
|
inputs.InstanceAddr,
|
||||||
cfg.Runner.Insecure,
|
cfg.Runner.TLSConfig(),
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
config.RequestTimeout,
|
config.RequestTimeout,
|
||||||
|
cfg.Runner.ExtraHeaders,
|
||||||
)
|
)
|
||||||
|
|
||||||
for {
|
if _, err := cli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{
|
||||||
_, err := cli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{
|
Data: inputs.RunnerName,
|
||||||
Data: inputs.RunnerName,
|
})); err != nil {
|
||||||
}))
|
return fmt.Errorf("cannot ping the Gitea instance server: %w", err)
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).
|
|
||||||
Errorln("Cannot ping the Gitea instance server")
|
|
||||||
// TODO: if ping failed, retry or exit
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
} else {
|
|
||||||
log.Debugln("Successfully pinged the Gitea instance server")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
log.Debugln("Successfully pinged the Gitea instance server")
|
||||||
|
|
||||||
reg := &config.Registration{
|
reg := &config.Registration{
|
||||||
Name: inputs.RunnerName,
|
Name: inputs.RunnerName,
|
||||||
|
|||||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user